Merge branch 'staging'

This commit is contained in:
jejolare 2025-09-14 17:17:09 +07:00
commit 52c02a14bc
128 changed files with 6291 additions and 3422 deletions

10
public/ui/premium.svg Normal file
View file

@ -0,0 +1,10 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="15" height="15" rx="7.5" fill="url(#paint0_radial_3055_10518)"/>
<path d="M3 11.6302H12V2.63024H3V11.6302Z" fill="white"/>
<defs>
<radialGradient id="paint0_radial_3055_10518" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="rotate(45) scale(21.2132 26.9658)">
<stop stop-color="#16D1D6"/>
<stop offset="1" stop-color="#274CFF"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 493 B

View file

@ -1 +1 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.688 6.75h13.5l-3.376-3.375M16.313 11.25h-13.5l3.374 3.375" stroke="#1F8FEB" stroke-width="1.5" stroke-linecap="square"></path></svg>
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.688 6.75h13.5l-3.376-3.375M16.313 11.25h-13.5l3.374 3.375" stroke="#1F8FEB" stroke-width="1.5" stroke-linecap="square"></path></svg>

Before

Width:  |  Height:  |  Size: 239 B

After

Width:  |  Height:  |  Size: 216 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="11.375" stroke="#1F8FEB" stroke-width="1.25"/>
<path d="M10.9898 14.6042V14.5354C10.9974 13.805 11.074 13.2237 11.2194 12.7916C11.3648 12.3595 11.5714 12.0096 11.8393 11.7419C12.1071 11.4742 12.4286 11.2275 12.8036 11.0019C13.0293 10.8642 13.2321 10.7017 13.412 10.5143C13.5918 10.3231 13.7334 10.1033 13.8367 9.85468C13.9439 9.60612 13.9974 9.33078 13.9974 9.02868C13.9974 8.65392 13.9094 8.32887 13.7334 8.05354C13.5574 7.7782 13.3221 7.56597 13.0274 7.41683C12.7328 7.26769 12.4056 7.19312 12.0459 7.19312C11.7321 7.19312 11.4298 7.25813 11.139 7.38815C10.8482 7.51816 10.6052 7.72275 10.4101 8.00191C10.2149 8.28107 10.102 8.64627 10.0714 9.09751H8.625C8.65561 8.44742 8.82398 7.89101 9.1301 7.4283C9.44005 6.96558 9.84758 6.61185 10.3527 6.36711C10.8616 6.12237 11.426 6 12.0459 6C12.7194 6 13.3048 6.13384 13.8023 6.40153C14.3036 6.66922 14.6901 7.03633 14.9617 7.50287C15.2372 7.96941 15.375 8.50096 15.375 9.09751C15.375 9.51816 15.3099 9.89866 15.1798 10.239C15.0536 10.5793 14.8699 10.8834 14.6288 11.1511C14.3916 11.4187 14.1046 11.6558 13.7679 11.8623C13.4311 12.0727 13.1614 12.2945 12.9585 12.5277C12.7557 12.7572 12.6084 13.0306 12.5166 13.348C12.4247 13.6654 12.375 14.0612 12.3673 14.5354V14.6042H10.9898ZM11.7245 18C11.4413 18 11.1983 17.8987 10.9955 17.696C10.7927 17.4933 10.6913 17.2505 10.6913 16.9675C10.6913 16.6845 10.7927 16.4417 10.9955 16.239C11.1983 16.0363 11.4413 15.935 11.7245 15.935C12.0077 15.935 12.2506 16.0363 12.4534 16.239C12.6563 16.4417 12.7577 16.6845 12.7577 16.9675C12.7577 17.1549 12.7098 17.327 12.6142 17.4837C12.5223 17.6405 12.398 17.7667 12.2411 17.8623C12.088 17.9541 11.9158 18 11.7245 18Z" fill="#1F8FEB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1,14 @@
import React from 'react';
import { classes } from '@/utils/utils';
import { ActionBtnProps } from './types';
import styles from './styles.module.scss';
const ActionBtn = ({ children, variant = 'primary', className, ...props }: ActionBtnProps) => {
return (
<button className={classes(styles.btn, className, styles[variant])} {...props}>
{children}
</button>
);
};
export default ActionBtn;

View file

@ -0,0 +1,33 @@
.btn {
cursor: pointer;
border: none;
outline: none;
padding: 6px 12px;
border-radius: 5px;
background: var(--action-btn-bg);
font-size: 12px;
font-weight: 500;
line-height: 100%;
min-width: max-content;
&:disabled {
cursor: not-allowed;
opacity: 0.5 !important;
}
&:hover {
background: var(--action-btn-bg);
}
&.primary {
color: #1f8feb;
}
&.success {
color: #16d1d6;
}
&.danger {
color: #ff6767;
}
}

View file

@ -0,0 +1,5 @@
import { ButtonHTMLAttributes } from 'react';
export interface ActionBtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'success' | 'danger';
}

View file

@ -1,17 +1,17 @@
.content__preloader__wrapper {
.loader {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
> div {
&__content {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
p {
&__text {
color: var(--font-dimmed-color);
}
}

View file

@ -1,12 +1,15 @@
import Preloader from '@/components/UI/Preloader/Preloader';
import { classes } from '@/utils/utils';
import styles from './ContentPreloader.module.scss';
import { ContentPreloaderProps } from './types';
function ContentPreloader(props: { className?: string }) {
function ContentPreloader({ className, style }: ContentPreloaderProps) {
return (
<div className={`${styles.content__preloader__wrapper} ${props.className}`}>
<div>
<div style={style} className={classes(styles.loader, className)}>
<div className={styles.loader__content}>
<Preloader />
<p>Loading...</p>
<p className={styles.loder__text}>Loading...</p>
</div>
</div>
);

View file

@ -0,0 +1,6 @@
import { CSSProperties } from 'react';
export interface ContentPreloaderProps {
className?: string;
style?: CSSProperties;
}

View file

@ -0,0 +1,16 @@
import React from 'react';
import { ReactComponent as NoOffersIcon } from '@/assets/images/UI/no_offers.svg';
import { classes } from '@/utils/utils';
import styles from './styles.module.scss';
import { EmptyMessageProps } from './types';
const EmptyMessage = ({ text, customIcon }: EmptyMessageProps) => {
return (
<div className={classes(styles.empty, styles.all__orders__msg)}>
{!customIcon ? <NoOffersIcon className={styles.empty__icon} /> : customIcon}
<h6 className={styles.empty__text}>{text}</h6>
</div>
);
};
export default EmptyMessage;

View file

@ -0,0 +1,17 @@
.empty {
width: 100%;
margin-top: 40px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
&__text {
color: var(--font-dimmed-color);
}
&__icon {
transform: scale(0.8);
}
}

View file

@ -0,0 +1,6 @@
import { ReactNode } from 'react';
export interface EmptyMessageProps {
text: string;
customIcon?: ReactNode;
}

View file

@ -4,8 +4,21 @@
overflow: auto;
padding-bottom: 3px;
&.sm {
gap: 5px !important;
> div {
a {
font-size: 14px !important;
padding: 8px !important;
border-radius: 8px !important;
line-height: 100%;
}
}
}
&.tab {
gap: 10px;
gap: 3px;
> div {
a {
@ -16,7 +29,8 @@
border-radius: 10px;
&.selected {
background-color: #1f8feb1a;
background-color: var(--tab-bg-color);
color: #fff;
&::after {
display: none;

View file

@ -2,6 +2,7 @@ import Link from 'next/link';
import { nanoid } from 'nanoid';
import HorizontalSelectProps from '@/interfaces/props/components/UI/HorizontalSelect/HorizontalSelectProps';
import HorizontalSelectValue from '@/interfaces/common/HorizontalSelectValue';
import { classes } from '@/utils/utils';
import NotificationIndicator from '../NotificationIndicator/NotificationIndicator';
import styles from './HorizontalSelect.module.scss';
@ -13,7 +14,12 @@ function HorizontalSelect<T extends HorizontalSelectValue>(props: HorizontalSele
return (
<div
style={props.withNotifications ? { paddingTop: '20px' } : {}}
className={`${styles.horizontal_select} ${className || ''} ${props.isTab ? styles.tab : ''}`}
className={classes(
styles.horizontal_select,
className,
props.isTab && styles.tab,
props.isSm && styles.sm,
)}
>
{props.body.map((e) => (
<div key={nanoid(16)}>

View file

@ -9,7 +9,7 @@
background: none;
outline: none;
border: none;
z-index: 5;
z-index: 2;
cursor: pointer;
}
@ -18,6 +18,13 @@
top: 30px;
transition: none;
transform: translateX(-50%);
box-shadow: 0px 4px 14px 0px #07072b59;
padding: 9px 12px;
p {
font-size: 14px;
font-weight: 500;
}
}
.range__slider {
@ -74,18 +81,4 @@
border: 2px solid var(--window-bg-color);
transition: background 0.3s ease-in-out;
}
// .input__range::-webkit-slider-runnable-track::-webkit-slider-thumb {
// background-color: #fff;
// border: 2px solid #555;
// }
// .input__range::-webkit-slider-runnable-track {
// -webkit-appearance: none;
// height: 20px;
// background-color: #1F8FEB;
// border: 1px solid #ffffff;
// box-shadow: none;
// background: transparent;
// }
}

View file

@ -38,7 +38,7 @@ function RangeInput(props: RangeInputProps) {
className={styles.input__range__tooltip}
shown={tooltipShown}
>
{realValue}%
<p>{realValue}%</p>
</Tooltip>
<div

View file

@ -0,0 +1,22 @@
import React from 'react';
import { classes } from '@/utils/utils';
import styles from './styles.module.scss';
import { TabsProps } from './types';
const Tabs = ({ type = 'tab', data, value, setValue }: TabsProps) => {
return (
<div className={classes(styles.tabs, styles[type])}>
{data.map((tab) => (
<button
key={tab.type}
onClick={() => setValue(tab)}
className={classes(styles.tabs__item, value.type === tab.type && styles.active)}
>
{tab.title} {Number.isFinite(tab.length) && <>({tab.length})</>}
</button>
))}
</div>
);
};
export default Tabs;

View file

@ -0,0 +1,48 @@
.tabs {
width: 100%;
border-bottom: 1px solid var(--delimiter-color);
display: flex;
align-items: center;
gap: 22px;
&.button {
flex-wrap: wrap;
gap: 5px;
border-bottom: none;
.tabs__item {
padding: 6px 10px;
border-radius: 25px;
font-size: 12px;
border: 1px solid var(--action-btn-bg);
&.active {
border-color: transparent;
background-color: #1f8feb;
}
}
}
&__item {
cursor: pointer;
padding-bottom: 7px;
position: relative;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
background-color: transparent;
color: #1f8feb;
&.active {
color: var(--text-color);
border-color: #1f8feb;
}
&:hover {
border-color: #1f8feb;
background-color: transparent;
}
}
}

View file

@ -0,0 +1,12 @@
export type tabsType = {
title: string;
type: string;
length?: number;
};
export interface TabsProps {
type?: 'tab' | 'button';
value: tabsType;
setValue: (_next: tabsType) => void;
data: tabsType[];
}

View file

@ -1,4 +1,13 @@
.back_btn {
display: flex;
gap: 12px;
&.sm {
padding: 12px 22px;
span {
font-size: 14px;
font-weight: 500;
}
}
}

View file

@ -1,16 +1,21 @@
import { ReactComponent as ArrowWhiteIcon } from '@/assets/images/UI/arrow_white.svg';
import Button from '@/components/UI/Button/Button';
import { useRouter } from 'next/router';
import { classes } from '@/utils/utils';
import styles from './BackButton.module.scss';
import { BackButtonProps } from './types';
function BackButton() {
function BackButton({ className, isSm }: BackButtonProps) {
const router = useRouter();
return (
<Button className={styles.back_btn} transparent={true} onClick={router.back}>
{/* <img src={ArrowWhiteIcon} alt="arrow"/> */}
<Button
className={classes(styles.back_btn, className, isSm && styles.sm)}
transparent={true}
onClick={router.back}
>
<ArrowWhiteIcon />
Back
<span>Back</span>
</Button>
);
}

View file

@ -0,0 +1,4 @@
export interface BackButtonProps {
className?: string;
isSm?: boolean;
}

View file

@ -29,12 +29,12 @@ const links: {
title: 'Auction',
type: 'auction',
link: 'https://wrapped.zano.org/',
disabled: true,
},
{
title: 'Messenger',
type: 'messenger',
link: 'https://zano.org/',
disabled: true,
link: 'https://messenger.zano.org/',
},
{
title: 'Wrapped Zano',

View file

@ -0,0 +1,163 @@
import { classes } from '@/utils/utils';
import React, { useMemo } from 'react';
import EmptyMessage from '@/components/UI/EmptyMessage';
import { useMediaQuery } from '@/hook/useMediaQuery';
import { GenericTableProps } from './types';
export default function GenericTable<T>(props: GenericTableProps<T>) {
const {
className,
tableClassName,
theadClassName,
tbodyClassName,
columns,
data,
getRowKey,
emptyMessage = 'No data',
getRowProps,
groupBy,
renderGroupHeader,
sortGroups,
responsive,
scrollRef,
} = props;
const isMatch = useMediaQuery(responsive?.query ?? '');
const mediaActive = !!responsive?.query && isMatch;
const effectiveColumns = useMemo(() => {
let cols = columns;
if (mediaActive && responsive?.hiddenKeys?.length) {
const hide = new Set(responsive.hiddenKeys);
cols = cols.filter((c) => !hide.has(c.key));
}
if (mediaActive && responsive?.alignOverride) {
cols = cols.map((c) => {
const ov = responsive.alignOverride?.[c.key];
return ov ? { ...c, align: ov } : c;
});
}
return cols;
}, [columns, mediaActive, responsive]);
const grouped = useMemo(() => {
if (!groupBy) return [{ key: '__all__', items: data }];
const map = new Map<string, T[]>();
for (const item of data) {
const k = String(groupBy(item));
const bucket = map.get(k) ?? [];
bucket.push(item);
map.set(k, bucket);
}
const entries = Array.from(map.entries());
if (sortGroups) entries.sort((a, b) => sortGroups(a[0], b[0]));
return entries.map(([key, items]) => ({ key, items }));
}, [data, groupBy, sortGroups]);
return (
<div className={className}>
{data.length > 0 ? (
<div
ref={scrollRef}
className="orders-scroll"
style={{ maxHeight: '100%', overflowY: 'auto' }}
>
<table
className={tableClassName}
style={{
tableLayout:
isMatch && responsive?.tableLayout
? responsive.tableLayout
: 'fixed',
width: '100%',
borderCollapse: 'separate',
borderSpacing: 0,
}}
>
<colgroup>
{effectiveColumns.map((col) => (
<col
key={col.key}
style={col.width ? { width: col.width } : undefined}
/>
))}
</colgroup>
<thead className={theadClassName}>
<tr>
{effectiveColumns.map((col) => (
<th
key={col.key}
className={col.className}
style={{
position: 'sticky',
top: '-1px',
zIndex: 2,
background: 'var(--window-bg-color)',
textAlign: col.align ?? 'left',
whiteSpace: 'nowrap',
overflowX: 'hidden',
textOverflow: 'ellipsis',
padding: '6px 10px',
fontSize: 11,
fontWeight: 700,
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody className={tbodyClassName}>
{grouped.map((group, gi) => (
<React.Fragment key={group.key}>
{renderGroupHeader && (
<tr className="__group-header-row">
<td colSpan={effectiveColumns.length}>
{renderGroupHeader({
groupKey: group.key,
items: group.items,
index: gi,
})}
</td>
</tr>
)}
{group.items.map((row, i) => (
<tr
{...(getRowProps ? getRowProps(row, i) : {})}
key={getRowKey(row, i)}
>
{effectiveColumns.map((col) => (
<td
key={col.key}
className={classes(col.className)}
style={{
textAlign: col.align ?? 'left',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
padding: '6px 10px',
verticalAlign: 'middle',
position: 'relative',
}}
>
{col.cell(row, i)}
</td>
))}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
) : (
<EmptyMessage text={emptyMessage} />
)}
</div>
);
}

View file

@ -0,0 +1,42 @@
export type Align = 'left' | 'center' | 'right';
export type ColumnDef<T> = {
key: string;
header: React.ReactNode;
width?: string;
align?: Align;
className?: string;
cell: (_row: T, _rowIndex: number) => React.ReactNode;
};
export type RowProps = React.HTMLAttributes<HTMLTableRowElement> & {
className?: string;
};
export type GroupHeaderRenderArgs<T> = {
groupKey: string;
items: T[];
index: number;
};
export type GenericTableProps<T> = {
className?: string;
tableClassName?: string;
theadClassName?: string;
tbodyClassName?: string;
columns: ColumnDef<T>[];
data: T[];
getRowKey: (_row: T, _rowIndex: number) => React.Key;
emptyMessage?: string;
getRowProps?: (_row: T, _index: number) => RowProps | undefined;
groupBy?: (_row: T) => string | number;
renderGroupHeader?: (_args: GroupHeaderRenderArgs<T>) => React.ReactNode;
sortGroups?: (_a: string, _b: string) => number;
responsive?: {
query: string;
hiddenKeys?: string[];
alignOverride?: Record<string, 'left' | 'center' | 'right'>;
tableLayout?: 'auto' | 'fixed';
};
scrollRef?: React.RefObject<HTMLDivElement>;
};

View file

@ -7,9 +7,53 @@
justify-content: space-between;
border-bottom: 1px solid var(--delimiter-color);
background-color: var(--main-bg-color);
z-index: 1;
z-index: 99;
position: relative;
&.lg {
padding-inline: 60px;
height: 65px;
.header__logo {
width: 180px;
height: 38px;
}
.header__currency__check {
height: 48px !important;
gap: 25px !important;
}
.header__account__wrapper {
.header__account__info {
p {
&:first-child {
font-size: 16px;
}
font-size: 14px;
}
}
.header__account__btn {
min-width: 48px !important;
height: 48px !important;
}
}
.header__connect_btn {
height: 50px;
}
@media screen and (max-width: 1600px) {
padding-inline: 40px;
}
@media screen and (max-width: 1200px) {
padding-inline: 20px;
}
}
.header__logo {
display: flex;
width: 202px;
@ -65,6 +109,7 @@
.header__login {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
transition: none;
}
@ -208,7 +253,7 @@
position: absolute;
top: 100%;
left: 0;
background-color: #273666;
background-color: var(--switch-bg-color);
border-radius: 0 0 10px 10px;
display: flex;
flex-direction: column;

View file

@ -18,7 +18,7 @@ import Button from '@/components/UI/Button/Button';
import { useWindowWidth } from '@react-hook/window-size';
import ConnectButton from '@/components/UI/ConnectButton/ConnectButton';
import { notationToString, setWalletCredentials, shortenAddress } from '@/utils/utils';
import { classes, notationToString, setWalletCredentials, shortenAddress } from '@/utils/utils';
import useAdvancedTheme from '@/hook/useTheme';
import { Store } from '@/store/store-reducer';
@ -33,7 +33,7 @@ import useUpdateUser from '@/hook/useUpdateUser';
import NavBar from './NavBar/NavBar';
import styles from './Header.module.scss';
function Header() {
function Header({ isLg }: { isLg?: boolean }) {
const { theme, setTheme } = useAdvancedTheme();
const router = useRouter();
@ -347,7 +347,7 @@ function Header() {
return (
<>
{menuOpened && <div className={styles.header__blur__block}></div>}
<header className={styles.header}>
<header className={classes(styles.header, isLg && styles.lg)}>
<div className={styles.header__logo}>
<Link href="/dex">
<img src={theme === 'dark' ? logoImg : logoImgWhite} alt="Zano P2P" />
@ -355,9 +355,11 @@ function Header() {
</div>
<div className={styles.header__desktop__navigation}>
<NavBar />
<NavBar isLg={isLg} />
</div>
{Menu()}
<BurgerButton className={styles.header__burger} />
<div className={styles.header__account__mobile} style={mobileHeaderStyle}>

View file

@ -1,4 +1,25 @@
.nav {
&.lg {
a {
gap: 8px;
h6 {
font-size: 14px;
font-weight: 600;
line-height: 140%;
}
svg {
transform: scale(0.9);
}
.badge {
padding: 3px;
min-width: 22px;
}
}
}
a {
display: flex;
align-items: center;

View file

@ -9,6 +9,7 @@ import NavBarProps from '@/interfaces/props/components/default/Header/NavBar/Nav
import NotificationIndicator from '@/components/UI/NotificationIndicator/NotificationIndicator';
import { useContext } from 'react';
import { Store } from '@/store/store-reducer';
import { classes } from '@/utils/utils';
import styles from './NavBar.module.scss';
function NavBar(props: NavBarProps) {
@ -40,14 +41,18 @@ function NavBar(props: NavBarProps) {
<Link href={href} className={linkClass}>
<Img />
<h6>{title}</h6>
<NotificationIndicator count={notifications} />
<NotificationIndicator className={styles.badge} count={notifications} />
</Link>
);
}
return (
<nav
className={`${styles.nav} ${!props.mobile ? styles.nav__desktop : styles.nav__mobile}`}
className={classes(
styles.nav,
!props.mobile ? styles.nav__desktop : styles.nav__mobile,
props.isLg && styles.lg,
)}
>
<NavItem
title={'Exchange'}

View file

@ -106,7 +106,7 @@ function PairsTable({ data }: IProps) {
cell: ({ row }) => (
<div className={styles.price_cell}>
<div className={styles.text}>
{roundTo(notationToString(row.original.price), 2)}
{roundTo(notationToString(row.original.price), 4)}
</div>
<div className={styles.sub_text}>{row.original.priceUSD}</div>
</div>
@ -149,7 +149,7 @@ function PairsTable({ data }: IProps) {
cell: ({ row }) => (
<div className={styles.price_cell}>
<div className={styles.text}>
{roundTo(notationToString(row.original?.volume ?? 0), 2)}
{roundTo(notationToString(row.original?.volume ?? 0), 4)}
</div>
<div className={styles.sub_text}>{row.original.volumeUSD}</div>
</div>

View file

@ -0,0 +1,98 @@
import { useEffect, useRef, useState } from 'react';
import Tooltip from '@/components/UI/Tooltip/Tooltip';
import { classes, cutAddress } from '@/utils/utils';
import MatrixConnectionBadge from '@/components/dex/MatrixConnectionBadge';
import BadgeStatus from '@/components/dex/BadgeStatus';
import { createPortal } from 'react-dom';
import styles from './styles.module.scss';
import { AliasCellProps } from './types';
export default function AliasCell({
alias,
address,
matrixAddresses,
isInstant,
isSm,
max = 12,
}: AliasCellProps) {
const display = alias ? cutAddress(alias, max) : 'no alias';
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const anchorRef = useRef<HTMLParagraphElement | null>(null);
const updatePosition = () => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
});
};
useEffect(() => {
if (!open) return;
updatePosition();
const onScrollOrResize = () => updatePosition();
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
}, [open]);
return (
<p ref={anchorRef} className={styles.alias}>
<span
onMouseEnter={() => {
setOpen(true);
requestAnimationFrame(updatePosition);
}}
onMouseLeave={() => setOpen(false)}
className={styles.alias__text}
>
@{display}
</span>
<MatrixConnectionBadge
userAdress={address}
userAlias={alias}
matrixAddresses={matrixAddresses}
isSm={isSm}
/>
{isInstant && (
<div style={{ marginLeft: 2 }}>
<BadgeStatus type="instant" icon />
</div>
)}
{open &&
pos &&
createPortal(
<>
{alias && alias.length > max && (
<Tooltip
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
transform: 'translateX(-50%)',
zIndex: 9999,
pointerEvents: 'auto',
}}
className={styles.tooltip}
arrowClass={styles.tooltip__arrow}
shown={true}
>
<p className={styles.tooltip__text}>{alias}</p>
</Tooltip>
)}
</>,
document.body,
)}
</p>
);
}

View file

@ -0,0 +1,33 @@
.alias {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
&__text {
color: var(--font-main-color) !important;
}
path {
fill: none;
}
}
.tooltip {
position: absolute;
top: 30px;
left: 5%;
background-color: var(--trade-table-tooltip);
z-index: 9999 !important;
&__text {
font-size: 12px !important;
color: var(--font-main-color) !important;
}
&__arrow {
border-radius: 2px;
left: 30% !important;
background-color: var(--trade-table-tooltip) !important;
}
}

View file

@ -0,0 +1,10 @@
import MatrixAddress from '@/interfaces/common/MatrixAddress';
export interface AliasCellProps {
alias?: string;
address?: string;
matrixAddresses: MatrixAddress[];
isInstant?: boolean;
isSm?: boolean;
max?: number;
}

View file

@ -0,0 +1,26 @@
import { classes } from '@/utils/utils';
import React from 'react';
import Image from 'next/image';
import LightningImg from '@/assets/images/UI/lightning.png';
import RocketImg from '@/assets/images/UI/rocket.png';
import styles from './styles.module.scss';
import { BadgeStatusProps } from './types';
function BadgeStatus({ type, icon }: BadgeStatusProps) {
return (
<div className={classes(styles.badge, type === 'high' && styles.high, icon && styles.icon)}>
<Image
className={styles.badge__img}
src={type === 'instant' ? LightningImg : RocketImg}
alt="badge image"
/>
{!icon && (
<span className={styles.badge__text}>
{type === 'instant' ? 'instant' : 'high volume'}
</span>
)}
</div>
);
}
export default BadgeStatus;

View file

@ -0,0 +1,39 @@
.badge {
width: fit-content;
padding: 1px 4px;
padding-right: 8px;
display: flex;
align-items: center;
gap: 2px;
border-radius: 100px;
background: radial-gradient(100% 246.57% at 0% 0%, #a366ff 0%, #601fff 100%);
&.high {
padding: 2px 4px;
background: radial-gradient(100% 188.88% at 0% 0%, #16d1d6 0%, #274cff 100%);
}
&.icon {
min-width: 15px;
height: 15px;
border-radius: 50%;
justify-content: center;
padding: 0;
.badge__img {
width: 11px;
height: 11px;
}
}
&__img {
height: 13px;
width: auto;
}
&__text {
font-size: 10px;
font-weight: 600;
color: #fff;
}
}

View file

@ -0,0 +1,4 @@
export interface BadgeStatusProps {
type?: 'instant' | 'high';
icon?: boolean;
}

View file

@ -0,0 +1,198 @@
import { useEffect, useState, useRef, useMemo } from 'react';
import useAdvancedTheme from '@/hook/useTheme';
import ReactECharts from 'echarts-for-react';
import type CandleChartProps from '@/interfaces/props/pages/dex/trading/CandleChartProps/CandleChartProps';
import styles from './styles.module.scss';
import { ResultCandle } from './types';
import {
buildCandles,
d,
diffFmt,
fmt,
pickWindowIndices,
tsLabel,
zoomStartByPeriod,
chartColors,
} from './utils';
function CandleChart(props: CandleChartProps) {
const { theme } = useAdvancedTheme();
const chartRef = useRef<ReactECharts>(null);
const [candles, setCandles] = useState<ResultCandle[]>([]);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setCandles(buildCandles(props.candles));
setIsLoaded(true);
}, [props.candles]);
// shown candle
const shownIdx = candles.length ? candles.length - 1 : null;
const prevIdx = shownIdx && shownIdx > 0 ? shownIdx - 1 : null;
const shown = shownIdx !== null ? candles[shownIdx] : undefined;
const prev = prevIdx !== null ? candles[prevIdx] : undefined;
const O = shown?.[3];
const H = shown?.[1];
const L = shown?.[2];
const C = shown?.[4];
const P = prev?.[4];
const delta = diffFmt(C, P);
const option = useMemo(() => {
const timestamps = candles.map((c) => c[0]);
const { startIdx, endIdx } = pickWindowIndices(timestamps, zoomStartByPeriod(props.period));
return {
grid: { top: '5%', left: '0%', right: '10%', bottom: '8%' },
xAxis: {
type: 'category',
boundaryGap: true,
min: 'dataMin',
max: 'dataMax',
splitLine: {
show: true,
lineStyle: {
color: theme === 'light' ? chartColors.light : chartColors.dark,
},
},
axisLine: { onZero: false },
axisLabel: { formatter: (v: string) => tsLabel(parseInt(v, 10)) },
axisPointer: {
show: true,
type: 'line',
label: {
formatter: (p: { value: string }) => tsLabel(parseInt(p.value, 10)),
backgroundColor: chartColors.default,
color: '#fff',
},
},
},
yAxis: {
scale: true,
position: 'right',
splitArea: { show: false },
min: (v: { min: number; max: number }) => {
const min = d(v.min);
const range = d(v.max).minus(min);
const pad = range.lte(0) ? d(v.max).mul(0.05) : range.mul(0.1);
return min.minus(pad).toNumber();
},
max: (v: { min: number; max: number }) => {
const min = d(v.min);
const max = d(v.max);
const range = max.minus(min);
const pad = range.lte(0) ? max.mul(0.05) : range.mul(0.1);
return max.plus(pad).toNumber();
},
splitLine: {
show: true,
lineStyle: {
color: theme === 'light' ? chartColors.light : chartColors.dark,
},
},
axisLabel: {
formatter: (val: number | string) => d(val).toDecimalPlaces(6).toString(),
},
axisPointer: {
show: true,
type: 'line',
label: {
show: true,
color: '#fff',
backgroundColor: (p: {
seriesData?: Array<{ value?: (number | string)[] }>;
}) => {
const ts = p.seriesData?.[0]?.value?.[0];
const idx = candles.findIndex((c) => c[0] === ts);
if (idx === -1) return chartColors.default;
const [, , , o, c] = candles[idx];
let color = chartColors.default;
if (c > o) {
color = chartColors.green;
} else if (c < o) {
color = chartColors.red;
}
return color;
},
},
},
},
dataZoom: [{ type: 'inside', startValue: startIdx, endValue: endIdx }],
series: [
{
name: 'Candle Chart',
type: 'candlestick',
data: candles,
barWidth: '75%',
itemStyle: {
color: chartColors.green,
color0: chartColors.red,
borderColor: chartColors.green,
borderColor0: chartColors.red,
},
dimensions: ['date', 'highest', 'lowest', 'open', 'close'],
encode: { x: 'date', y: ['open', 'close', 'highest', 'lowest'] },
large: true,
largeThreshold: 2_000_000,
},
],
};
}, [candles, props.period, theme]);
return (
<div className={styles.chart}>
{/* Header */}
<div className={styles.chart__top}>
<div className={styles.chart__top_item}>
<p>
{props.currencyNames.firstCurrencyName}/
{props.currencyNames.secondCurrencyName}
</p>
</div>
<div className={styles.chart__top_item}>
<p style={{ opacity: 0.7 }}>O</p>
<span style={{ color: delta.color }}>{fmt(O)}</span>
</div>
<div className={styles.chart__top_item}>
<span style={{ opacity: 0.7 }}>H</span>
<span style={{ color: delta.color }}>{fmt(H)}</span>
</div>
<div className={styles.chart__top_item}>
<span style={{ opacity: 0.7 }}>L</span>
<span style={{ color: delta.color }}>{fmt(L)}</span>
</div>
<div className={styles.chart__top_item}>
<span style={{ opacity: 0.7 }}>C</span>
<span style={{ color: delta.color }}>{fmt(C)}</span>
</div>
<div className={styles.chart__top_item}>
<p style={{ color: delta.color }}>{delta.txt}</p>
</div>
</div>
<ReactECharts
ref={chartRef}
option={option}
style={{ height: '100%', width: '100%' }}
opts={{ devicePixelRatio: 2 }}
lazyUpdate
notMerge
/>
{!candles.length && isLoaded && (
<h1 className={styles.chart__lowVolume}>[ Low volume ]</h1>
)}
</div>
);
}
export default CandleChart;

View file

@ -0,0 +1,66 @@
.chart {
position: relative;
width: auto;
height: auto;
height: 100%;
&__top {
padding-bottom: 10px;
background-color: var(--main-bg-color);
width: 100%;
position: absolute;
left: 0;
top: 18px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
z-index: 2;
&_item {
display: flex;
align-items: center;
gap: 2px;
p {
color: var(--footer-selected-link);
}
p,
span {
font-size: 11px;
font-weight: 400;
line-height: 100%;
white-space: nowrap;
}
}
@media screen and (max-width: 1280px) {
padding-bottom: 0;
}
}
> canvas {
width: 100%;
height: 100%;
cursor: crosshair;
}
&__lowVolume {
font-size: 72px;
color: var(--font-dimmed-color);
white-space: nowrap;
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
@media screen and (max-width: 600px) {
font-size: 48px;
}
@media screen and (max-width: 400px) {
font-size: 36px;
}
}
}

View file

@ -0,0 +1 @@
export type ResultCandle = [ts: number, high: number, low: number, open: number, close: number];

View file

@ -0,0 +1,116 @@
import CandleRow from '@/interfaces/common/CandleRow';
import CandleChartProps from '@/interfaces/props/pages/dex/trading/CandleChartProps/CandleChartProps';
import Decimal from 'decimal.js';
import * as echarts from 'echarts';
import { ResultCandle } from './types';
import testCandles from './testCandles.json';
export const TESTING_MODE = false;
export const chartColors = {
green: '#16D1D6',
red: '#FF6767',
default: '#3D3D6C',
light: '#e3e3e8',
dark: '#1f1f4a',
textColor: '#ffffff',
};
export const d = (v: number | string) => new Decimal(v);
export const fmt = (n?: number | string) =>
n === undefined || n === null || Number.isNaN(Number(n))
? '-'
: d(n).toDecimalPlaces(6).toString();
export function diffFmt(curr?: number | string, prev?: number | string) {
if (curr === undefined || prev === undefined || Number(prev) === 0) {
return { txt: '-', color: '#9CA3AF' };
}
const diff = d(curr).minus(prev);
const pct = diff.div(prev).mul(100);
let sign = '';
if (diff.greaterThan(0)) {
sign = '+';
} else if (diff.isZero()) {
sign = '';
}
const txt = `${sign}${diff.toDecimalPlaces(8).toString()} (${sign}${pct.toDecimalPlaces(2).toString()}%)`;
let color = '#9CA3AF';
if (diff.greaterThan(0)) {
color = chartColors.green;
} else if (diff.lessThan(0)) {
color = chartColors.red;
}
return { txt, color };
}
const isTodayTs = (ms: number) => new Date(ms).toDateString() === new Date().toDateString();
export const tsLabel = (ms: number) =>
echarts.format.formatTime(isTodayTs(ms) ? 'hh:mm:ss' : 'hh:mm:ss\ndd-MM-yyyy', ms);
export function buildCandles(src: CandleRow[]): ResultCandle[] {
const arr = (TESTING_MODE ? (testCandles as CandleRow[]) : src).map(
(c) =>
[
parseInt(c.timestamp, 10),
c.shadow_top || 0,
c.shadow_bottom || 0,
c.body_first || 0,
c.body_second || 0,
] as ResultCandle,
);
return arr
.filter((e) => e[0])
.map((e) => {
for (let i = 1; i < 5; i++) if (d(e[i]).lessThan(0.00001)) e[i] = 0;
return e;
});
}
export function zoomStartByPeriod(period: CandleChartProps['period']) {
const now = Date.now();
const hr = 3600_000;
switch (period) {
// case '1sec':
// return now - 1_000;
case '1min':
return now - hr / 60;
case '5min':
return now - 5 * (hr / 60);
case '15min':
return now - 15 * (hr / 60);
case '30min':
return now - 30 * (hr / 60);
case '1h':
return now - hr * 24;
case '4h':
return now - hr * 4;
case '1d':
return now - hr * 24 * 7;
case '1w':
return now - hr * 24 * 7 * 4;
case '1m':
return now - hr * 24 * 7 * 52;
default:
return now - hr;
}
}
export function pickWindowIndices(timestamps: number[], startTs: number) {
if (!timestamps.length) return { startIdx: 0, endIdx: 0 };
const nearest = timestamps.reduce(
(a, b) => (Math.abs(b - startTs) < Math.abs(a - startTs) ? b : a),
timestamps[0],
);
return {
startIdx: timestamps.indexOf(nearest),
endIdx: timestamps.length - 1,
};
}

View file

@ -0,0 +1,37 @@
import LabeledInputProps from '@/interfaces/props/pages/dex/trading/InputPanelItem/LabeledInputProps';
import { classes } from '@/utils/utils';
import { useRef } from 'react';
import Input from '@/components/UI/Input/Input';
import styles from './styles.module.scss';
function LabeledInput(props: LabeledInputProps) {
const labelRef = useRef<HTMLParagraphElement>(null);
const { label = '', currency = '', value, readonly, setValue, invalid } = props;
const handleInput = (e: React.FormEvent<HTMLInputElement>) => {
if (!readonly && setValue) {
setValue(e.currentTarget.value);
}
};
return (
<div className={styles.labeledInput}>
<h6 className={styles.labeledInput__label}>{label}</h6>
<div className={classes(styles.labeledInput__wrapper, invalid && styles.invalid)}>
<Input
bordered
placeholder="0.00"
value={value}
readOnly={readonly}
onInput={handleInput}
/>
<div className={styles.labeledInput__currency} ref={labelRef}>
<p>{currency}</p>
</div>
</div>
</div>
);
}
export default LabeledInput;

View file

@ -0,0 +1,84 @@
.labeledInput {
display: flex;
flex-direction: column;
gap: 8px;
&__label {
font-size: 12px;
font-family: 500;
line-height: 100%;
color: var(--table-th-color);
}
&__wrapper {
width: 100%;
position: relative;
background-color: var(--bordered-input-bg);
border: 1px solid transparent;
border-radius: 8px;
display: flex;
overflow: hidden;
&:hover {
border-color: var(--window-border-color);
}
&:focus-within {
border-color: #1f8feb;
}
&.invalid {
border-color: #ff6767;
}
input {
width: 100%;
padding: 15px;
background-color: transparent;
border: none;
font-size: 14px;
font-weight: 400;
}
}
&__currency {
max-width: 150px;
padding: 0 15px;
display: flex;
align-items: center;
justify-content: center;
> p {
font-size: 14px;
font-weight: 400;
white-space: nowrap;
overflow: hidden;
line-height: 100%;
text-overflow: ellipsis;
}
}
}
@media screen and (max-width: 640px) {
.labeledInput {
gap: 5px;
&__label {
font-size: 10px;
}
&__wrapper {
input {
padding: 11px 8px;
}
}
&__currency {
padding: 0 8px;
> p {
font-size: 12px;
}
}
}
}

View file

@ -0,0 +1,275 @@
import { Store } from '@/store/store-reducer';
import { createOrder } from '@/utils/methods';
import { ChangeEvent, useContext, useState } from 'react';
import RangeInput from '@/components/UI/RangeInput/RangeInput';
import ConnectButton from '@/components/UI/ConnectButton/ConnectButton';
import Button from '@/components/UI/Button/Button';
import { useRouter } from 'next/router';
import { classes, formatDollarValue, notationToString } from '@/utils/utils';
import InputPanelItemProps from '@/interfaces/props/pages/dex/trading/InputPanelItem/InputPanelItemProps';
import CreateOrderData from '@/interfaces/fetch-data/create-order/CreateOrderData';
import Decimal from 'decimal.js';
import Alert from '@/components/UI/Alert/Alert';
import infoIcon from '@/assets/images/UI/info_alert_icon.svg';
import Image from 'next/image';
import { useAlert } from '@/hook/useAlert';
import { buySellValues } from '@/constants';
import { usePathname, useSearchParams } from 'next/navigation';
import styles from './styles.module.scss';
import LabeledInput from './components/LabeledInput';
function InputPanelItem(props: InputPanelItemProps) {
const {
priceState = '',
amountState = '',
totalState = '',
buySellState = buySellValues[0],
setBuySellState,
setPriceFunction,
setAmountFunction,
setRangeInputValue,
rangeInputValue = '50',
balance = 0,
zanoBalance = 0,
amountValid,
priceValid,
totalValid,
totalUsd,
scrollToOrderList,
currencyNames,
onAfter,
} = props;
const { state } = useContext(Store);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { setAlertState, setAlertSubtitle } = useAlert();
const [creatingState, setCreatingState] = useState(false);
const { firstCurrencyName, secondCurrencyName } = currencyNames;
const [hasImmediateMatch, setHasImmediateMatch] = useState(false);
const isBuy = buySellState?.code === 'buy';
function goToSuitableTab() {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', 'matches');
router.replace(`${pathname}?${params.toString()}`, undefined, {
shallow: true,
scroll: false,
});
}
function resetForm() {
setPriceFunction('');
setAmountFunction('');
setRangeInputValue('50');
}
async function postOrder() {
const price = new Decimal(priceState);
const amount = new Decimal(amountState);
const total = new Decimal(totalState);
const isFull =
price.greaterThan(0) &&
price.lessThan(1000000000) &&
amount.greaterThan(0) &&
amount.lessThan(1000000000) &&
total.greaterThan(0);
if (!isFull) return;
if (isBuy) {
const zanoAmount = new Decimal(zanoBalance);
if (zanoAmount.lessThan(total)) {
setAlertState('error');
setAlertSubtitle('Insufficient ZANO balance');
setTimeout(() => setAlertState(null), 3000);
return;
}
} else {
const assetAmount = new Decimal(balance);
if (assetAmount.lessThan(amount)) {
setAlertState('error');
setAlertSubtitle(`Insufficient ${firstCurrencyName} balance`);
setTimeout(() => setAlertState(null), 3000);
return;
}
}
const orderData: CreateOrderData = {
type: isBuy ? 'buy' : 'sell',
side: 'limit',
price: price.toString(),
amount: amount.toString(),
pairId: typeof router.query.id === 'string' ? router.query.id : '',
};
setCreatingState(true);
const result = await createOrder(orderData);
setCreatingState(false);
if (result.success) {
if (result.data?.immediateMatch) {
setHasImmediateMatch(true);
}
onAfter();
resetForm();
} else {
setAlertState('error');
if (result.data === 'Same order') {
setAlertSubtitle('Order already exists');
} else {
setAlertSubtitle('Failed to create order');
}
setTimeout(() => {
setAlertState(null);
setAlertSubtitle('');
}, 3000);
}
}
function onRangeInput(e: ChangeEvent<HTMLInputElement>) {
setRangeInputValue(e.target.value);
if (balance > 0) {
const rangeValue = new Decimal(e.target.value || '0');
const balanceDecimal = new Decimal(balance);
const calculatedAmount = balanceDecimal.mul(rangeValue.div(100)).toString();
setAmountFunction(calculatedAmount || '');
}
}
const buttonText = creatingState ? 'Creating...' : 'Create Order';
const isButtonDisabled = !priceValid || !amountValid || !totalValid || creatingState;
const showTotalError = priceState !== '' && amountState !== '' && !totalValid;
return (
<div className={styles.inputPanel}>
{hasImmediateMatch && (
<Alert
type="custom"
customContent={
<div className={styles.applyAlert}>
<Image src={infoIcon} alt="success" width={64} height={64} />
<div className={styles.applyAlert__content}>
<h2>Apply the order</h2>
<p>You have to apply the order</p>
<Button
className={styles.applyAlert__button}
onClick={() => {
scrollToOrderList();
goToSuitableTab();
setHasImmediateMatch(false);
}}
>
Apply now
</Button>
</div>
</div>
}
close={() => setHasImmediateMatch(false)}
/>
)}
<div className={styles.inputPanel__header}>
<h5 className={styles.title}>Trade</h5>
</div>
<div className={styles.inputPanel__body}>
<div className={styles.inputPanel__selector}>
<button
onClick={() => setBuySellState(buySellValues[1])}
className={classes(
styles.inputPanel__selector_item,
buySellState.code === 'buy' && styles.buy,
)}
>
Buy
</button>
<button
onClick={() => setBuySellState(buySellValues[2])}
className={classes(
styles.inputPanel__selector_item,
buySellState.code === 'sell' && styles.sell,
)}
>
Sell
</button>
</div>
<LabeledInput
value={priceState}
setValue={setPriceFunction}
currency={secondCurrencyName}
label="Price"
invalid={!!priceState && !priceValid}
/>
<LabeledInput
value={amountState}
setValue={setAmountFunction}
currency={firstCurrencyName}
label="Quantity"
invalid={!!amountState && !amountValid}
/>
<div className={classes(isBuy && styles.disabled)}>
<RangeInput value={!isBuy ? rangeInputValue : '50'} onInput={onRangeInput} />
<div className={styles.inputPanel__body_labels}>
<p className={styles.inputPanel__body_labels__item}>0%</p>
<p className={styles.inputPanel__body_labels__item}>100%</p>
</div>
</div>
<div className={styles.inputPanel__body_labels}>
<p className={styles.inputPanel__body_labels__item}>
Available <span className={styles.balance}>Balance</span>
</p>
<p className={styles.inputPanel__body_labels__item}>
<span>{balance || 0}</span> {firstCurrencyName}
</p>
</div>
<div className={styles.inputPanel__body_total}>
<LabeledInput
value={amountState && priceState && notationToString(totalState)}
setValue={() => undefined}
currency={secondCurrencyName}
label="Total"
readonly={true}
invalid={showTotalError}
/>
<div className={classes(styles.inputPanel__body_labels, styles.mobileWrap)}>
<p className={styles.inputPanel__body_labels__item}>
Fee: <span>0.01</span> ZANO
</p>
<p className={styles.inputPanel__body_labels__item}>
~ ${formatDollarValue(totalUsd || '0')}
</p>
</div>
</div>
{state.wallet?.connected ? (
<Button
disabled={isButtonDisabled}
onClick={postOrder}
className={classes(
styles.inputPanel__body_btn,
isBuy ? styles.buy : styles.sell,
)}
>
{buttonText}
</Button>
) : (
<ConnectButton className={styles.inputPanel__body_btn} />
)}
</div>
</div>
);
}
export default InputPanelItem;

View file

@ -0,0 +1,202 @@
.inputPanel {
width: 100%;
height: 100%;
padding: 15px;
background: var(--window-bg-color);
border: 1px solid var(--delimiter-color);
border-radius: 10px;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12px;
border-bottom: 1px solid var(--delimiter-color);
margin-bottom: 10px;
.title {
font-size: 16px;
font-weight: 600;
}
}
&__selector {
display: flex;
background-color: var(--selector-bg-color);
padding: 4px;
border-radius: 20px;
&_item {
cursor: pointer;
width: 50%;
background-color: transparent;
border-radius: 100px;
padding-block: 9px;
font-size: 14px;
font-weight: 500;
line-height: 100%;
&:hover {
background-color: transparent;
opacity: 0.8;
}
&.buy {
background-color: #16d1d6;
color: #fff;
}
&.sell {
background-color: #ff6767;
color: #fff;
}
}
}
&__body {
display: flex;
flex-direction: column;
gap: 15px;
&_labels {
margin-top: 5px;
display: flex;
align-items: center;
justify-content: space-between;
&__item {
font-size: 12px;
font-weight: 500;
line-height: 100%;
color: var(--table-th-color);
.balance {
color: var(--table-th-color);
}
span {
font-size: 12px;
}
}
}
&_total {
display: flex;
flex-direction: column;
gap: 5px;
}
&_btn {
margin-top: 10px;
&.buy {
background-color: #16d1d6;
&:hover {
background-color: #45dade;
}
}
&.sell {
background-color: #ff6767;
&:hover {
background-color: #ff8585;
}
}
}
}
}
.disabled {
opacity: .5;
pointer-events: none;
}
.applyAlert {
display: flex;
gap: 20px;
align-items: center;
&__content {
display: flex;
flex-direction: column;
gap: 10px;
}
&__button {
max-width: 125px;
background-color: var(--alert-btn-bg);
color: #1f8feb;
padding: 7px 32px;
font-size: 12px;
font-weight: 500;
&:hover {
background-color: var(--alert-btn-hover);
}
}
h2 {
font-size: 16px;
font-weight: 600;
}
p {
font-size: 14px;
opacity: 0.7;
margin-bottom: 5px;
}
}
@media screen and (max-width: 640px) {
.inputPanel {
padding: 0;
border: none;
background: transparent;
&__header {
display: none;
}
&__selector {
padding: 0;
&_item {
padding-block: 6px;
font-size: 12px;
}
}
&__body {
gap: 10px;
&_labels {
&.mobileWrap {
gap: 10px;
flex-direction: column-reverse;
.inputPanel__body_labels__item:last-child {
margin-left: auto;
}
}
&__item,
&__item span {
font-size: 10px;
.balance {
display: none;
}
}
}
&_btn {
margin-top: 0;
padding: 12px;
font-size: 12px;
line-height: 100%;
}
}
}
}

View file

@ -0,0 +1,88 @@
import Tooltip from '@/components/UI/Tooltip/Tooltip';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ReactComponent as ConnectionIcon } from '@/assets/images/UI/connection.svg';
import { classes } from '@/utils/utils';
import styles from './styles.module.scss';
import { MatrixConnectionBadgeProps } from './types';
function MatrixConnectionBadge({
userAdress,
userAlias,
matrixAddresses,
isSm,
}: MatrixConnectionBadgeProps) {
const hasConnection = (address: string) =>
matrixAddresses.some((item) => item.address === address && item.registered);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const anchorRef = useRef<HTMLParagraphElement | null>(null);
const updatePosition = () => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({
top: rect.bottom + 8,
left: rect.left + rect.width / 2,
});
};
useEffect(() => {
if (!open) return;
updatePosition();
const onScrollOrResize = () => updatePosition();
window.addEventListener('scroll', onScrollOrResize, true);
window.addEventListener('resize', onScrollOrResize);
return () => {
window.removeEventListener('scroll', onScrollOrResize, true);
window.removeEventListener('resize', onScrollOrResize);
};
}, [open]);
if (!userAdress || !hasConnection(userAdress)) return <></>;
return (
<div className={classes(styles.badge, isSm && styles.sm)}>
<p
ref={anchorRef}
onClick={(e) => {
e.preventDefault();
window.open(`https://matrix.to/#/@${userAlias}:zano.org`);
}}
onMouseEnter={() => {
setOpen(true);
requestAnimationFrame(updatePosition);
}}
onMouseLeave={() => setOpen(false)}
className={styles.badge__link}
>
<ConnectionIcon />
</p>
{open &&
pos &&
createPortal(
<Tooltip
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
transform: 'translateX(-50%)',
zIndex: 9999,
pointerEvents: 'auto',
}}
className={styles.badge__tooltip}
arrowClass={styles.badge__tooltip_arrow}
shown={true}
>
<p className={styles.badge__tooltip_text}>Matrix connection</p>
</Tooltip>,
document.body,
)}
</div>
);
}
export default MatrixConnectionBadge;

View file

@ -0,0 +1,36 @@
.badge {
position: relative;
&.sm {
.badge__link svg {
width: 14px;
height: 14px;
}
}
&__link {
margin-top: 4px;
cursor: pointer;
svg {
width: 16px;
height: 16px;
}
}
&__tooltip {
padding: 10px;
background-color: var(--trade-table-tooltip);
font-size: 12px;
&_text {
font-size: 12px !important;
}
&_arrow {
border-radius: 2px;
left: 50%;
background-color: var(--trade-table-tooltip) !important;
}
}
}

View file

@ -0,0 +1,8 @@
import MatrixAddress from '@/interfaces/common/MatrixAddress';
export interface MatrixConnectionBadgeProps {
isSm?: boolean;
userAdress?: string;
userAlias?: string;
matrixAddresses: MatrixAddress[];
}

View file

@ -0,0 +1,52 @@
import Tooltip from '@/components/UI/Tooltip/Tooltip';
import { useState } from 'react';
import styles from './styles.module.scss';
import { OrderRowTooltipCellProps } from './types';
function OrderRowTooltipCell({
style,
children,
sideText,
sideTextColor,
noTooltip,
}: OrderRowTooltipCellProps) {
const [showTooltip, setShowTooltip] = useState(false);
const tooltipText = `${children}${sideText ? ` ~${sideText}` : ''}`;
const isLongContent = tooltipText.length > 14;
return (
<td className={styles.row}>
<p
style={style}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{children}
{sideText && (
<span
style={{
fontSize: '15px',
margin: 0,
color: sideTextColor || 'var(--font-dimmed-color)',
}}
>
{sideText}
</span>
)}
</p>
{isLongContent && !noTooltip && (
<Tooltip
className={styles.tooltip}
arrowClass={styles.tooltip__arrow}
shown={showTooltip}
>
{tooltipText}
</Tooltip>
)}
</td>
);
}
export default OrderRowTooltipCell;

View file

@ -0,0 +1,28 @@
.row {
position: relative;
&:last-child {
> p {
text-align: right;
}
}
> p {
min-width: 80px;
width: 100%;
font-size: 12px;
font-weight: 400;
}
}
.tooltip {
position: absolute;
top: 30px;
left: 20%;
transform: translateX(-50%);
background-color: var(--trade-table-tooltip);
&__arrow {
background-color: var(--trade-table-tooltip);
}
}

View file

@ -0,0 +1,9 @@
import { ReactNode } from 'react';
export interface OrderRowTooltipCellProps {
style?: React.CSSProperties;
children: string | ReactNode;
sideText?: string;
sideTextColor?: string;
noTooltip?: boolean;
}

View file

@ -0,0 +1,70 @@
import { formatTimestamp, notationToString } from '@/utils/utils';
import { ColumnDef } from '@/components/default/GenericTable/types';
import { PageOrderData } from '@/interfaces/responses/orders/GetOrdersPageRes';
import { Trade } from '@/interfaces/responses/trades/GetTradeRes';
import { BuildColumnsArgs } from './types';
import TotalUsdCell from '../../TotalUsdCell';
import styles from '../styles.module.scss';
export function buildOrderPoolColumns({
firstCurrencyName,
secondCurrencyName,
}: BuildColumnsArgs): ColumnDef<PageOrderData>[] {
return [
{
key: 'price',
header: <>Price ({secondCurrencyName})</>,
width: '80px',
cell: (row) => (
<p style={{ color: row.type === 'buy' ? '#16D1D6' : '#FF6767' }}>
{notationToString(row.price, 8)}
</p>
),
},
{
key: 'quantity',
header: <>Qty ({firstCurrencyName})</>,
width: '80px',
cell: (row) => <p>{notationToString(row.left, 8)}</p>,
},
{
key: 'total',
header: <>Total ({secondCurrencyName})</>,
width: '80px',
align: 'right',
className: styles.hideTotalSm,
cell: (row) => <TotalUsdCell amount={row.left} price={row.price} fixed={8} />,
},
];
}
export function buildTradesColumns({
firstCurrencyName,
secondCurrencyName,
}: BuildColumnsArgs): ColumnDef<Trade>[] {
return [
{
key: 'price',
header: <>Price ({secondCurrencyName})</>,
width: '80px',
cell: (row) => (
<p style={{ color: row.type === 'buy' ? '#16D1D6' : '#FF6767' }}>
{notationToString(row.price)}
</p>
),
},
{
key: 'quantity',
header: <>Qty ({firstCurrencyName})</>,
width: '80px',
cell: (row) => <p>{notationToString(row.amount)}</p>,
},
{
key: 'time',
align: 'right',
header: <>Time</>,
width: '80px',
cell: (row) => <p>{formatTimestamp(row.timestamp)}</p>,
},
];
}

View file

@ -0,0 +1,4 @@
export interface BuildColumnsArgs {
firstCurrencyName: string;
secondCurrencyName: string;
}

View file

@ -0,0 +1,408 @@
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
classes,
createOrderSorter,
cutAddress,
formatDollarValue,
notationToString,
} from '@/utils/utils';
import { nanoid } from 'nanoid';
import Decimal from 'decimal.js';
import Tooltip from '@/components/UI/Tooltip/Tooltip';
import ContentPreloader from '@/components/UI/ContentPreloader/ContentPreloader';
import { buySellValues } from '@/constants';
import { PageOrderData } from '@/interfaces/responses/orders/GetOrdersPageRes';
import useMouseLeave from '@/hook/useMouseLeave';
import { tabsType } from '@/components/UI/Tabs/types';
import Tabs from '@/components/UI/Tabs';
import GenericTable from '@/components/default/GenericTable';
import styles from './styles.module.scss';
import BadgeStatus from '../BadgeStatus';
import { OrdersPoolProps } from './types';
import { buildOrderPoolColumns, buildTradesColumns } from './columns';
const tabsData: tabsType[] = [
{
title: 'Order Pool',
type: 'orders',
},
{
title: 'Recent Trades',
type: 'trades',
},
];
const OrdersPool = (props: OrdersPoolProps) => {
const {
ordersBuySell,
setOrdersBuySell,
currencyNames,
ordersLoading,
ordersHistory,
filteredOrdersHistory,
secondAssetUsdPrice,
takeOrderClick,
trades,
tradesLoading,
} = props;
const ordersInfoRef = useRef<HTMLTableSectionElement>(null);
const scrollRef = useRef<HTMLTableSectionElement>(null);
const ordersMiddleRef = useRef<HTMLDivElement>(null);
const { firstCurrencyName, secondCurrencyName } = currencyNames;
const [infoTooltipPos, setInfoTooltipPos] = useState({ x: 0, y: 0 });
const [ordersInfoTooltip, setOrdersInfoTooltip] = useState<PageOrderData | null>(null);
const [currentOrder, setCurrentOrder] = useState<tabsType>(tabsData[0]);
const totals = useMemo(() => {
let buyTotal = new Decimal(0);
let sellTotal = new Decimal(0);
let maxBuyRow = new Decimal(0);
let maxSellRow = new Decimal(0);
for (const o of ordersHistory) {
const qty = new Decimal(o.amount || 0);
const price = new Decimal(o.price || 0);
const rowTotal = qty.mul(price);
if (o.type === 'buy') {
buyTotal = buyTotal.plus(rowTotal);
if (rowTotal.gt(maxBuyRow)) maxBuyRow = rowTotal;
} else if (o.type === 'sell') {
sellTotal = sellTotal.plus(rowTotal);
if (rowTotal.gt(maxSellRow)) maxSellRow = rowTotal;
}
}
const totalZano = buyTotal.plus(sellTotal);
const pct = (part: Decimal, whole: Decimal) =>
whole.gt(0) ? part.mul(100).div(whole) : new Decimal(0);
const buyPct = pct(buyTotal, totalZano);
const sellPct = pct(sellTotal, totalZano);
return {
buyTotal,
sellTotal,
totalZano,
buyPct,
sellPct,
maxBuyRow,
maxSellRow,
};
}, [ordersHistory]);
const toDisplayPair = (buyPctDec: Decimal, sellPctDec: Decimal) => {
const MIN_DISPLAY_PCT = 1;
const buyRaw = buyPctDec.toNumber();
const sellRaw = sellPctDec.toNumber();
if (!Number.isFinite(buyRaw) || !Number.isFinite(sellRaw)) return { buy: 0, sell: 0 };
if (buyRaw === 0 && sellRaw === 0) return { buy: 0, sell: 0 };
if (buyRaw === 0) return { buy: 0, sell: 100 };
if (sellRaw === 0) return { buy: 100, sell: 0 };
let buyDisp = Math.floor(buyRaw);
let sellDisp = Math.floor(sellRaw);
if (buyDisp < MIN_DISPLAY_PCT) buyDisp = MIN_DISPLAY_PCT;
if (sellDisp < MIN_DISPLAY_PCT) sellDisp = MIN_DISPLAY_PCT;
const diff = 100 - (buyDisp + sellDisp);
if (diff !== 0) {
if (buyRaw >= sellRaw) buyDisp += diff;
else sellDisp += diff;
}
buyDisp = Math.max(0, Math.min(100, buyDisp));
sellDisp = Math.max(0, Math.min(100, sellDisp));
return { buy: buyDisp, sell: sellDisp };
};
const { buy: buyDisp, sell: sellDisp } = toDisplayPair(totals.buyPct, totals.sellPct);
const moveInfoTooltip = (event: React.MouseEvent) => {
setInfoTooltipPos({ x: event.clientX, y: event.clientY });
};
const ordersPool = useMemo(
() =>
buildOrderPoolColumns({
firstCurrencyName,
secondCurrencyName,
}),
[firstCurrencyName, secondCurrencyName],
);
const tradeOrders = useMemo(
() =>
buildTradesColumns({
firstCurrencyName,
secondCurrencyName,
}),
[firstCurrencyName, secondCurrencyName],
);
useLayoutEffect(() => {
if (!scrollRef.current) return;
const parent = scrollRef.current;
if (ordersBuySell.code === 'all' && ordersMiddleRef.current) {
const child = ordersMiddleRef.current;
const parentRect = parent.getBoundingClientRect();
const childRect = child.getBoundingClientRect();
const scrollTop =
childRect.top -
parentRect.top +
parent.scrollTop -
parent.clientHeight / 2 +
childRect.height / 2;
parent.scrollTop = Math.round(scrollTop);
} else {
parent.scrollTop = 0;
}
}, [ordersLoading, filteredOrdersHistory.length, ordersBuySell.code]);
const sortedTrades = createOrderSorter<PageOrderData>({
getPrice: (e) => e.price,
getSide: (e) => e.type,
});
const renderTable = () => {
switch (currentOrder.type) {
case 'orders':
return (
<>
{!ordersLoading ? (
<div onMouseMove={moveInfoTooltip} ref={ordersInfoRef}>
<GenericTable
className={styles.ordersPool__content_orders}
tableClassName={styles.table}
tbodyClassName={styles.table__body}
theadClassName={styles.table__header}
columns={ordersPool}
data={filteredOrdersHistory.sort(sortedTrades)}
getRowKey={(r) => r.id}
groupBy={(r) => r.type}
scrollRef={scrollRef}
renderGroupHeader={({ groupKey }) => {
if (groupKey === 'buy') {
return (
<div ref={ordersMiddleRef} style={{ height: 0 }} />
);
}
}}
getRowProps={(row) => {
const rowTotalZano = new Decimal(row.left || 0).mul(
new Decimal(row.price || 0),
);
const denom =
row.type === 'buy'
? totals.maxBuyRow
: totals.maxSellRow;
const widthPct = denom.gt(0)
? rowTotalZano.mul(100).div(denom)
: new Decimal(0);
return {
className: styles[row.type],
style: {
'--precentage': `${widthPct.toDecimalPlaces(2).toString()}%`,
} as React.CSSProperties,
onClick: (event) => takeOrderClick(event, row),
onMouseMove: (event) => {
const tr = event.target as HTMLElement;
if (tr.classList.contains('alias'))
setOrdersInfoTooltip(null);
},
onMouseEnter: () => setOrdersInfoTooltip(row),
onMouseLeave: () => setOrdersInfoTooltip(null),
};
}}
responsive={{
query: '(max-width: 640px)',
hiddenKeys: ['total'],
alignOverride: { quantity: 'right' },
tableLayout: 'auto',
}}
/>
</div>
) : (
<ContentPreloader style={{ marginTop: 40 }} />
)}
</>
);
case 'trades':
return (
<>
{!tradesLoading ? (
<GenericTable
className={classes(styles.ordersPool__content_orders, styles.full)}
tableClassName={styles.table}
tbodyClassName={styles.table__body}
theadClassName={styles.table__header}
columns={tradeOrders}
data={trades.slice(0, 100)}
getRowKey={(r) => r.id}
/>
) : (
<ContentPreloader style={{ marginTop: 40 }} />
)}
</>
);
default:
return null;
}
};
useMouseLeave(ordersInfoRef, () => setOrdersInfoTooltip(null));
return (
<>
<div className={styles.ordersPool}>
<div className={styles.ordersPool__header}>
<Tabs value={currentOrder} setValue={setCurrentOrder} data={tabsData} />
{currentOrder.type === 'orders' && (
<div className={styles.ordersPool__header_type}>
<button
onClick={() => setOrdersBuySell(buySellValues[0])}
className={classes(
styles.btn,
styles.all,
ordersBuySell.code === 'all' && styles.selected,
)}
></button>
<button
onClick={() => setOrdersBuySell(buySellValues[1])}
className={classes(
styles.btn,
styles.buy,
ordersBuySell.code === 'buy' && styles.selected,
)}
>
B
</button>
<button
onClick={() => setOrdersBuySell(buySellValues[2])}
className={classes(
styles.btn,
styles.sell,
ordersBuySell.code === 'sell' && styles.selected,
)}
>
S
</button>
</div>
)}
</div>
<div className={styles.ordersPool__content}>
{renderTable()}
{currentOrder.type === 'orders' && !ordersLoading && totals.totalZano.gt(0) && (
<div className={styles.ordersPool__content_stats}>
<div
style={{ '--width': `${buyDisp}%` } as React.CSSProperties}
className={classes(styles.stat_item, styles.buy)}
>
<div className={styles.stat_item__badge}>B</div>
{buyDisp}%
</div>
<div
style={{ '--width': `${sellDisp}%` } as React.CSSProperties}
className={classes(styles.stat_item, styles.sell)}
>
{sellDisp}%<div className={styles.stat_item__badge}>S</div>
</div>
</div>
)}
</div>
</div>
{/* Order tooltip */}
{ordersInfoTooltip &&
(() => {
const totalDecimal = new Decimal(ordersInfoTooltip?.left).mul(
new Decimal(ordersInfoTooltip?.price),
);
const totalValue = secondAssetUsdPrice
? totalDecimal.mul(secondAssetUsdPrice).toFixed(2)
: undefined;
return (
<Tooltip
key={nanoid(16)}
className={styles.tooltip}
arrowClass={styles.tooltip__arrow}
style={{
left: infoTooltipPos.x,
top: infoTooltipPos.y + 20,
}}
shown
>
<div>
<h6>Alias</h6>
<p>
@{cutAddress(ordersInfoTooltip?.user?.alias || 'no alias', 12)}{' '}
{ordersInfoTooltip?.isInstant && (
<BadgeStatus type="instant" icon />
)}
</p>
<h6>Price ({secondCurrencyName})</h6>
<p
style={{
color:
ordersInfoTooltip?.type === 'buy'
? '#16D1D6'
: '#FF6767',
}}
>
{ordersInfoTooltip?.price}
</p>
<span>
~
{secondAssetUsdPrice && ordersInfoTooltip?.price !== undefined
? (() => {
const total = new Decimal(secondAssetUsdPrice).mul(
ordersInfoTooltip.price,
);
if (total.abs().lt(0.01)) {
return `$${total
.toFixed(8)
.replace(/(\.\d*?[1-9])0+$/, '$1')
.replace(/\.0+$/, '')}`;
}
return `$${total.toFixed(2).replace(/\.0+$/, '')}`;
})()
: 'undefined'}
</span>
<h6>Amount ({firstCurrencyName})</h6>
<p>{notationToString(ordersInfoTooltip?.left)}</p>
<h6>Total ({secondCurrencyName})</h6>
<p>{notationToString(totalDecimal.toString())}</p>
<span>
~{' '}
{totalValue ? `$${formatDollarValue(totalValue)}` : 'undefined'}
</span>
</div>
</Tooltip>
);
})()}
</>
);
};
export default OrdersPool;

View file

@ -0,0 +1,265 @@
.ordersPool {
position: relative;
width: 100%;
height: 100%;
padding: 5px;
background: var(--window-bg-color);
border: 1px solid var(--delimiter-color);
border-radius: 10px;
&__header {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
&_type {
display: flex;
align-items: center;
gap: 8px;
.btn {
cursor: pointer;
width: 20px;
height: 20px;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
transition: 0.3s opacity ease;
color: #ffffff;
opacity: 60%;
&.selected,
&:hover {
opacity: 100%;
}
&.all {
background: linear-gradient(to left, #ff6767 50%, #16d1d6 50%);
}
&.buy {
background-color: #16d1d6;
}
&.sell {
background-color: #ff6767;
}
}
}
}
&__content {
display: flex;
flex-direction: column;
padding-top: 10px;
width: 100%;
&_orders {
width: 100%;
height: 410px;
&.full {
height: 480px;
}
.table {
width: 100%;
&__header {
position: relative;
z-index: 3;
th {
color: var(--table-th-color);
}
}
&__body {
tr {
cursor: pointer;
position: relative;
&:hover {
background-color: var(--table-tr-hover-color);
}
&.buy {
td {
&:last-child {
&::before {
background-color: var(--dex-buy-percentage);
}
}
}
}
&.sell {
td {
&:last-child {
&::before {
background-color: var(--dex-sell-percentage);
}
}
}
}
td {
position: static !important;
&:last-child {
&::before {
content: '';
pointer-events: none;
position: absolute;
z-index: 1;
right: 0;
top: 0;
width: var(--precentage);
height: 100%;
}
}
p,
span {
position: relative;
z-index: 2;
font-size: 11px;
font-weight: 400;
}
}
}
}
}
}
&_stats {
position: absolute;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
width: calc(100% - 30px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1px;
.stat_item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
line-height: 100%;
font-weight: 400;
min-width: 60px;
&.buy {
width: var(--width);
background-color: var(--dex-buy-percentage);
color: #16d1d6;
.stat_item__badge {
background-color: #16d1d6;
}
}
&.sell {
width: var(--width);
background-color: var(--dex-sell-percentage);
justify-content: flex-end;
color: #ff6767;
.stat_item__badge {
background-color: #ff6767;
}
}
&__badge {
width: 20px;
height: 20px;
border-radius: 4px;
display: grid;
place-content: center;
font-size: 12px;
font-weight: 700;
line-height: 120%;
color: #ffffff;
}
}
}
}
}
.tooltip {
pointer-events: none;
position: fixed;
border: 1px solid var(--dex-tooltip-border-color);
min-width: 140px;
max-width: 300px;
padding: 10px;
transform: translateX(-50%);
background-color: var(--dex-tooltip-bg);
&__arrow {
border-top: 1px solid var(--dex-tooltip-border-color);
background-color: var(--dex-tooltip-bg) !important;
}
h6 {
color: var(--table-th-color);
margin-top: 12px;
font-size: 11px;
font-weight: 700;
&:first-child {
margin-top: 0;
}
}
p {
font-size: 12px;
font-weight: 400;
display: flex;
align-items: center;
gap: 5px;
margin-top: 6px;
}
span {
margin-top: 5px;
display: block;
color: #8d95ae;
font-size: 11px;
font-weight: 400;
}
}
@media screen and (max-width: 640px) {
.ordersPool {
padding: 0;
border: none;
background: transparent;
&__header {
display: none;
}
&__content {
padding-top: 0;
&_orders {
height: 350px;
}
&_stats {
margin-top: 22px;
position: relative;
width: 100%;
}
}
}
.tooltip {
display: none;
}
}

View file

@ -0,0 +1,23 @@
import { Dispatch, SetStateAction } from 'react';
import SelectValue from '@/interfaces/states/pages/dex/trading/InputPanelItem/SelectValue';
import { PageOrderData } from '@/interfaces/responses/orders/GetOrdersPageRes';
import { Trade } from '@/interfaces/responses/trades/GetTradeRes';
export interface OrdersPoolProps {
ordersBuySell: SelectValue;
secondAssetUsdPrice: number | undefined;
setOrdersBuySell: Dispatch<SetStateAction<SelectValue>>;
currencyNames: {
firstCurrencyName: string;
secondCurrencyName: string;
};
ordersLoading: boolean;
ordersHistory: PageOrderData[];
filteredOrdersHistory: PageOrderData[];
trades: Trade[];
tradesLoading: boolean;
takeOrderClick: (
_event: React.MouseEvent<HTMLTableRowElement, MouseEvent>,
_e: PageOrderData,
) => void;
}

View file

@ -0,0 +1,25 @@
import { useMemo } from 'react';
import Decimal from 'decimal.js';
import { notationToString, formatDollarValue, classes } from '@/utils/utils';
import { TotalUsdCellProps } from './types';
export default function TotalUsdCell({
amount,
price,
secondAssetUsdPrice,
fixed,
className,
}: TotalUsdCellProps) {
const total = useMemo(
() => new Decimal(amount || 0).mul(new Decimal(price || 0)),
[amount, price],
);
const usd = secondAssetUsdPrice ? total.mul(secondAssetUsdPrice).toFixed(2) : undefined;
return (
<p className={classes(className)}>
{notationToString((fixed ? total.toFixed(fixed) : total).toString())}{' '}
{secondAssetUsdPrice && <span>~ ${usd && formatDollarValue(usd)}</span>}
</p>
);
}

View file

@ -0,0 +1,7 @@
export interface TotalUsdCellProps {
amount: string | number;
price: string | number;
secondAssetUsdPrice?: number;
fixed?: number;
className?: string;
}

View file

@ -0,0 +1,23 @@
import { shortenAddress } from '@/utils/utils';
import Link from 'next/link';
import CurrencyIcon from '../CurrencyIcon';
import styles from './styles.module.scss';
import { AssetRowProps } from './types';
const AssetRow = ({ name, link, id, code }: AssetRowProps) => (
<div className={styles.asset}>
<p className={styles.asset__name}>
<CurrencyIcon code={code} size={16} /> {name}:
</p>
<Link
className={styles.asset__address}
rel="noopener noreferrer"
target="_blank"
href={link}
>
{shortenAddress(id)}
</Link>
</div>
);
export default AssetRow;

View file

@ -0,0 +1,19 @@
.asset {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
&__name {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 400;
}
&__address {
font-size: 14px;
font-weight: 400;
}
}

View file

@ -0,0 +1,6 @@
export interface AssetRowProps {
name: string;
link: string;
id: string;
code: string | undefined | null;
}

View file

@ -0,0 +1,9 @@
import Image from 'next/image';
import { getAssetIcon } from '@/utils/utils';
import { CurrencyIconProps } from './types';
const CurrencyIcon = ({ code, size = 50 }: CurrencyIconProps) => (
<Image width={size} height={size} src={getAssetIcon(String(code))} alt="currency" />
);
export default CurrencyIcon;

View file

@ -0,0 +1,4 @@
export interface CurrencyIconProps {
code: string | undefined | null;
size?: number;
}

View file

@ -0,0 +1,32 @@
import StatItemProps from '@/interfaces/props/pages/dex/trading/StatItemProps';
import { classes } from '@/utils/utils';
import styles from './styles.module.scss';
function StatItem({ Img, title, value, className, coefficient }: StatItemProps) {
return (
<div className={classes(styles.statItem, className)}>
<div className={styles.statItem__top}>
<Img />
<p className={styles.statItem__top_title}>{title}</p>
</div>
<div className={styles.statItem__content}>
<p className={styles.statItem__content_val}>{value}</p>
{coefficient !== undefined && (
<p
className={classes(
styles.statItem__content_coefficient,
coefficient >= 0 ? styles.green : styles.red,
)}
>
{coefficient >= 0 ? '+' : ''}
{coefficient?.toFixed(2)}%
</p>
)}
</div>
</div>
);
}
export default StatItem;

View file

@ -0,0 +1,48 @@
.statItem {
display: flex;
flex-direction: column;
gap: 6px;
&__top {
display: flex;
align-items: center;
gap: 5px;
svg {
transform: scale(0.9);
}
&_title {
color: var(--footer-selected-link);
white-space: nowrap;
font-size: 12px;
font-weight: 400;
}
}
&__content {
display: flex;
align-items: center;
gap: 5px;
&_val {
white-space: nowrap;
font-size: 14px;
font-weight: 500;
}
&_coefficient {
white-space: nowrap;
font-size: 14px;
font-weight: 500;
&.green {
color: #16d1d6;
}
&.red {
color: #ff6767;
}
}
}
}

View file

@ -0,0 +1,135 @@
import { ReactComponent as ClockIcon } from '@/assets/images/UI/clock_icon.svg';
import { ReactComponent as UpIcon } from '@/assets/images/UI/up_icon.svg';
import { ReactComponent as DownIcon } from '@/assets/images/UI/down_icon.svg';
import { ReactComponent as VolumeIcon } from '@/assets/images/UI/volume_icon.svg';
import BackButton from '@/components/default/BackButton/BackButton';
import { roundTo, notationToString, classes } from '@/utils/utils';
// import questionIcon from '@/assets/images/UI/question.svg';
// import Image from 'next/image';
import styles from './styles.module.scss';
import StatItem from './components/StatItem';
import { TradingHeaderProps } from './types';
import CurrencyIcon from './components/CurrencyIcon';
import AssetRow from './components/AssetRow';
const TradingHeader = ({
pairStats,
pairRateUsd,
firstAssetLink,
secondAssetLink,
firstAssetId,
secondAssetId,
pairData,
}: TradingHeaderProps) => {
const currencyNames = {
firstCurrencyName: pairData?.first_currency?.name || '',
secondCurrencyName: pairData?.second_currency?.name || '',
};
const { firstCurrencyName, secondCurrencyName } = currencyNames;
const coefficient = pairStats?.coefficient || 0;
const coefficientOutput =
parseFloat(coefficient?.toFixed(2) || '0') === -100
? -99.99
: parseFloat(coefficient?.toFixed(2) || '0');
const stats = [
{
Img: ClockIcon,
title: '24h change',
value: `${roundTo(notationToString(pairStats?.rate || 0), 4)}`,
coefficient: coefficientOutput,
},
{
Img: UpIcon,
title: '24h high',
value: `${roundTo(notationToString(pairStats?.high || 0), 4)}`,
},
{
Img: DownIcon,
title: '24h low',
value: `${roundTo(notationToString(pairStats?.low || 0), 4)}`,
},
{
Img: VolumeIcon,
title: `24h volume (${firstCurrencyName})`,
value: `${roundTo(notationToString(pairStats?.volume || 0), 4)}`,
},
];
return (
<div className={styles.header}>
<div className={styles.header__stats}>
<div className={styles.header__currency}>
<div className={styles.header__currency_icon}>
<CurrencyIcon code={firstAssetId} />
</div>
<div className={styles.header__currency_item}>
<p className={styles.currencyName}>
{!pairData ? (
'...'
) : (
<>
{firstCurrencyName}
<span>/{secondCurrencyName}</span>
</>
)}
</p>
<div className={styles.price}>
<p
className={classes(
styles.price__secondCurrency,
coefficientOutput >= 0 ? styles.green : styles.red,
)}
>
{roundTo(notationToString(pairStats?.rate || 0, 8))}
</p>
{pairRateUsd && <p className={styles.price__usd}>~ ${pairRateUsd}</p>}
</div>
</div>
</div>
{pairData && firstAssetLink && secondAssetLink && (
<div className={styles.header__stats_assets}>
<AssetRow
name={firstCurrencyName}
link={firstAssetLink}
id={firstAssetId || ''}
code={firstAssetId}
/>
<AssetRow
name={secondCurrencyName}
link={secondAssetLink}
id={secondAssetId || ''}
code={secondAssetId}
/>
</div>
)}
{stats.map(({ Img, title, value, coefficient }) => (
<StatItem
key={title}
className={styles.header__stats_item}
Img={Img}
title={title}
value={value}
coefficient={coefficient}
/>
))}
</div>
<div className={styles.header__actions}>
{/* <button className={styles.header__actions_guide}>
<Image src={questionIcon} width={24} height={24} alt="guide" />
</button> */}
<BackButton className={styles.header__actions_backBtn} isSm />
</div>
</div>
);
};
export default TradingHeader;

View file

@ -0,0 +1,172 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 25px;
position: relative;
border: none;
&__currency {
display: flex;
align-items: center;
gap: 10px;
padding-right: 20px;
border-right: 1px solid var(--delimiter-color);
&_icon {
min-width: 40px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--icon-bg-color);
border-radius: 50%;
> img {
width: 24px;
height: auto;
}
}
&_item {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 6px;
.currencyName {
font-size: 16px;
font-weight: 600;
span {
color: var(--footer-selected-link);
}
}
.price {
display: flex;
align-items: center;
gap: 5px;
&__secondCurrency {
font-weight: 400;
font-size: 14px;
&.green {
color: #16d1d6;
}
&.red {
color: #ff6767;
}
}
&__usd {
color: var(--footer-selected-link);
font-size: 12px;
font-weight: 400;
}
}
}
}
&__stats {
display: flex;
flex-wrap: nowrap;
gap: 20px;
&_assets {
display: flex;
flex-direction: column;
gap: 7px;
}
&_item {
padding-left: 20px;
border-left: 1px solid var(--delimiter-color);
}
}
&__actions {
display: flex;
align-items: center;
gap: 20px;
&_guide {
cursor: pointer;
background-color: transparent;
&:hover {
background-color: transparent;
}
}
}
}
@media screen and (max-width: 1180px) {
.header {
&__currency {
border-right: none;
padding-right: 0;
}
&__stats_assets {
display: none;
}
}
}
@media screen and (max-width: 980px) {
.header {
&__currency {
padding-right: 20px;
border-right: 1px solid var(--delimiter-color);
}
&__stats_item {
padding-left: 0;
border-left: none;
&:last-child {
display: none !important;
}
}
&__actions_backBtn {
min-width: 40px;
height: 40px;
padding: 0 !important;
display: grid;
place-content: center;
span {
display: none;
}
}
}
}
@media screen and (max-width: 720px) {
.header__stats_item {
&:nth-child(5) {
display: none;
}
}
}
@media screen and (max-width: 580px) {
.header {
&__currency {
border-right: none;
padding-right: 0;
}
&__stats_item {
display: none !important;
}
&__actions {
gap: 12px;
}
}
}

View file

@ -0,0 +1,12 @@
import PairData from '@/interfaces/common/PairData';
import { PairStats } from '@/interfaces/responses/orders/GetPairStatsRes';
export interface TradingHeaderProps {
pairStats: PairStats | null;
pairRateUsd: string | undefined;
firstAssetLink?: string;
secondAssetLink?: string;
firstAssetId?: string | null;
secondAssetId?: string | null;
pairData: PairData | null;
}

View file

@ -0,0 +1,251 @@
import React from 'react';
import { classes, formatTimestamp, notationToString } from '@/utils/utils';
import TotalUsdCell from '@/components/dex/TotalUsdCell';
import EmptyMessage from '@/components/UI/EmptyMessage';
import AliasCell from '@/components/dex/AliasCell';
import { UniversalCardsProps } from './types';
import styles from '../styles.module.scss';
import RequestActionCell from '../../components/RequestActionCell';
import CancelActionCell from '../../components/CancelActionCell';
const OffersRow = ({
matches,
requests,
offers,
mobile,
}: {
matches: number;
requests: number;
offers: number;
mobile?: boolean;
}) => (
<div
className={classes(
styles.card__row,
styles.sm,
styles.card__offers,
mobile && styles.mobile,
)}
>
<div className={styles.card__col}>
<p className={styles.card__label}>Matches</p>
<p
className={classes(
styles.card__value,
matches > 0 ? styles.primary : styles.secondary,
)}
>
{matches}
</p>
</div>
<div className={styles.card__col}>
<p className={styles.card__label}>Requests</p>
<p
className={classes(
styles.card__value,
requests > 0 ? styles.primary : styles.secondary,
)}
>
{requests}
</p>
</div>
<div className={styles.card__col}>
<p className={styles.card__label}>Offers</p>
<p
className={classes(
styles.card__value,
offers > 0 ? styles.primary : styles.secondary,
)}
>
{offers}
</p>
</div>
</div>
);
const AliasRow = ({ label = 'Alias', children }: { label?: string; children: React.ReactNode }) => (
<div className={styles.card__col}>
<p className={styles.card__label}>{label}</p>
<div className={styles.card__value}>{children}</div>
</div>
);
export default function UniversalCards(props: UniversalCardsProps) {
const { firstCurrencyName, secondCurrencyName, secondAssetUsdPrice, data, onAfter } = props;
if (!data?.length)
return (
<div className={styles.cards}>
<EmptyMessage text="No data" />
</div>
);
return (
<div className={styles.cards}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{data.map((row: any) => {
const side: 'buy' | 'sell' = row.type ?? row.creator;
const Header = (
<div className={styles.card__row}>
<div className={classes(styles.card__col, styles.card__pair)}>
<p className={styles.card__label}>Pair</p>
<p className={styles.card__value}>
{firstCurrencyName}/{secondCurrencyName}
<span className={styles.card__type}>{side}</span>
</p>
</div>
<div className={classes(styles.card__col, styles.card__col_direction)}>
<p className={styles.card__label}>Direction</p>
<p className={classes(styles.card__value, styles.card__type)}>{side}</p>
</div>
<div className={styles.card__col}>
<p className={styles.card__label}>Time</p>
<p className={styles.card__value}>
{formatTimestamp(Number(row.timestamp))}
</p>
</div>
<div className={classes(styles.card__col, styles.card__price)}>
<p className={styles.card__label}>Price ({secondCurrencyName})</p>
<p className={styles.card__value}>{notationToString(row.price)}</p>
</div>
</div>
);
const Metrics = (
<>
<div className={styles.card__col}>
<p className={styles.card__label}>Quantity ({firstCurrencyName})</p>
<p className={styles.card__value}>
{notationToString(row.amount ?? row.left)}
</p>
</div>
<div className={styles.card__col}>
<p className={styles.card__label}>Total ({secondCurrencyName})</p>
<TotalUsdCell
className={styles.card__value}
amount={row.amount ?? row.left}
price={row.price}
secondAssetUsdPrice={secondAssetUsdPrice}
/>
</div>
<div
className={classes(styles.card__col, styles.card__price, styles.mobile)}
>
<p className={styles.card__label}>Price ({secondCurrencyName})</p>
<p className={styles.card__value}>{notationToString(row.price)}</p>
</div>
</>
);
let MiddleBlock: React.ReactNode = null;
let BottomRowLeft: React.ReactNode = null;
let Actions: React.ReactNode = null;
if (props.type === 'orders') {
const id = String(row.id);
const matches = props.matchesCountByOrderId[id] ?? 0;
const requests = props.requestsCountByOrderId[id] ?? 0;
const offers = props.offersCountByOrderId[id] ?? 0;
MiddleBlock = (
<OffersRow matches={matches} requests={requests} offers={offers} />
);
BottomRowLeft = (
<OffersRow matches={matches} requests={requests} offers={offers} mobile />
);
Actions = (
<div className={styles.card__actions}>
<CancelActionCell id={row.id} onAfter={onAfter} />
</div>
);
}
if (props.type === 'matches' || props.type === 'offers') {
MiddleBlock = null;
BottomRowLeft = (
<AliasRow>
<AliasCell
alias={row.user?.alias}
address={row.user?.address}
matrixAddresses={props.matrixAddresses}
isInstant={row.isInstant}
/>
</AliasRow>
);
const connectedOrder = props.userOrders?.find(
(o) => String(o.id) === String(row.connected_order_id),
);
Actions = (
<div className={styles.card__actions}>
<RequestActionCell
type={props.type === 'matches' ? 'request' : 'accept'}
row={row}
pairData={props.pairData}
connectedOrder={connectedOrder}
onAfter={onAfter}
/>
{props.type === 'offers' && (
<CancelActionCell
type="reject"
id={row.connected_order_id}
onAfter={onAfter}
/>
)}
</div>
);
}
if (props.type === 'requests') {
MiddleBlock = null;
BottomRowLeft = (
<AliasRow>
<AliasCell
alias={row.finalizer?.alias}
address={row.finalizer?.address}
matrixAddresses={props.matrixAddresses}
/>
</AliasRow>
);
Actions = (
<div className={styles.card__actions}>
<CancelActionCell
id={String(
row.creator === 'sell' ? row.sell_order_id : row.buy_order_id,
)}
onAfter={onAfter}
/>
</div>
);
}
if (props.type === 'history') {
MiddleBlock = null;
BottomRowLeft = null;
Actions = null;
}
return (
<div key={row.id} className={classes(styles.card, styles[side])}>
{Header}
<div className={styles.card__flex}>
<div className={styles.card__row}>
{Metrics}
{MiddleBlock}
</div>
<div className={styles.card__row}>
{BottomRowLeft}
{Actions}
</div>
</div>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,40 @@
import OrderRow from '@/interfaces/common/OrderRow';
import ApplyTip from '@/interfaces/common/ApplyTip';
import UserPendingType from '@/interfaces/common/UserPendingType';
import { UserOrderData } from '@/interfaces/responses/orders/GetUserOrdersRes';
import MatrixAddress from '@/interfaces/common/MatrixAddress';
import PairData from '@/interfaces/common/PairData';
export type CardsType = 'orders' | 'matches' | 'offers' | 'requests' | 'history';
type Common = {
firstCurrencyName: string;
secondCurrencyName: string;
secondAssetUsdPrice?: number;
onAfter: () => Promise<void>;
};
export type UniversalCardsProps =
| (Common & {
type: 'orders';
data: OrderRow[] | UserOrderData[];
matchesCountByOrderId: Record<string, number>;
requestsCountByOrderId: Record<string, number>;
offersCountByOrderId: Record<string, number>;
})
| (Common & {
type: 'matches' | 'offers';
data: ApplyTip[];
matrixAddresses: MatrixAddress[];
userOrders: OrderRow[] | undefined;
pairData: PairData | null;
})
| (Common & {
type: 'requests';
data: UserPendingType[];
matrixAddresses: MatrixAddress[];
})
| (Common & {
type: 'history';
data: UserOrderData[];
});

View file

@ -0,0 +1,206 @@
.cards {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 15px;
padding: 6px 10px;
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 100%;
}
&.sell {
&::before {
background-color: #ff6767;
}
.card__type {
color: #ff6767;
text-transform: capitalize;
}
}
&.buy {
&::before {
background-color: #16d1d6;
}
.card__type {
color: #16d1d6;
text-transform: capitalize;
}
}
&__row {
width: 100%;
display: flex;
align-items: center;
gap: 40px;
&.sm {
gap: 18px;
.card__col {
min-width: 50px;
}
}
}
&__label {
color: var(--table-th-color);
font-size: 12px;
font-weight: 500;
line-height: 100%;
}
&__value {
font-size: 12px;
font-weight: 500;
line-height: 100%;
&.primary {
color: #1f8feb;
}
&.secondary {
color: var(--table-th-color);
}
span {
color: var(--table-th-color);
font-size: 11px;
font-weight: 500;
line-height: 100%;
}
}
&__col {
min-width: 100px;
display: flex;
flex-direction: column;
gap: 10px;
}
&__pair {
.card__value {
display: flex;
align-items: center;
gap: 8px;
}
.card__type {
display: none;
}
}
&__flex {
gap: 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
}
}
.mobile {
display: none;
}
}
@media screen and (max-width: 640px) {
.cards .card {
gap: 10px;
&__row {
gap: 15px;
&.sm {
gap: 5px;
}
}
&__col {
min-width: 80px;
&_direction {
display: none;
}
}
&__label {
font-size: 10px;
}
&__value {
font-size: 11px;
}
&__pair {
.card__label {
display: none;
}
.card__value {
font-size: 12px;
}
.card__type {
font-size: 12px;
display: inline-block;
}
}
}
}
@media screen and (max-width: 500px) {
.cards .card {
&__row {
justify-content: space-between;
&.sm {
justify-content: flex-start;
}
}
&__col {
min-width: auto;
}
&__flex {
flex-direction: column;
}
&__price {
display: none;
&.mobile {
display: flex;
}
}
&__offers {
display: none;
&.mobile {
display: flex;
}
}
}
}

View file

@ -0,0 +1,369 @@
import { notationToString, formatTimestamp } from '@/utils/utils';
import type OrderRow from '@/interfaces/common/OrderRow';
import type { ColumnDef } from '@/components/default/GenericTable/types';
import ApplyTip from '@/interfaces/common/ApplyTip';
import UserPendingType from '@/interfaces/common/UserPendingType';
import { UserOrderData } from '@/interfaces/responses/orders/GetUserOrdersRes';
import CancelActionCell from '../components/CancelActionCell';
import AliasCell from '../../AliasCell';
import TotalUsdCell from '../../TotalUsdCell';
import RequestActionCell from '../components/RequestActionCell';
import {
BuildApplyTipsColumnsArgs,
BuildMyRequestsColumnsArgs,
BuildOrderHistoryColumnsArgs,
BuildUserColumnsArgs,
} from './types';
export function buildUserColumns({
firstCurrencyName,
secondCurrencyName,
secondAssetUsdPrice,
matchesCountByOrderId,
offersCountByOrderId,
requestsCountByOrderId,
onAfter,
}: BuildUserColumnsArgs): ColumnDef<OrderRow>[] {
return [
{
key: 'pair',
header: 'Pair',
width: '120px',
cell: (row) => (
<p
style={
{
'--direction-color': row.type === 'buy' ? '#16D1D6' : '#FF6767',
} as React.CSSProperties
}
>
{firstCurrencyName}/{secondCurrencyName}
</p>
),
},
{
key: 'direction',
header: 'Direction',
width: '110px',
cell: (row) => (
<p
style={{
color: row.type === 'buy' ? '#16D1D6' : '#FF6767',
textTransform: 'capitalize',
}}
>
{row.type}
</p>
),
},
{
key: 'price',
header: <>Price ({secondCurrencyName})</>,
width: '150px',
cell: (row) => <p>{notationToString(row.price)}</p>,
},
{
key: 'quantity',
header: <>Quantity ({firstCurrencyName})</>,
width: '160px',
cell: (row) => <p>{notationToString(row.left)}</p>,
},
{
key: 'total',
header: <>Total ({secondCurrencyName})</>,
width: '180px',
cell: (row) => (
<TotalUsdCell
amount={row.left}
price={row.price}
secondAssetUsdPrice={secondAssetUsdPrice}
/>
),
},
{
key: 'matches',
header: 'Matches',
width: '70px',
cell: (row) => {
const count = matchesCountByOrderId[row.id] ?? 0;
return (
<p
style={{
fontWeight: 500,
color: count > 0 ? '#1F8FEB' : '#B6B6C4',
}}
>
{count}
</p>
);
},
},
{
key: 'requests',
header: 'Requests',
width: '70px',
cell: (row) => {
const count = requestsCountByOrderId[row.id] ?? 0;
return (
<p
style={{
fontWeight: 500,
color: count > 0 ? '#1F8FEB' : '#B6B6C4',
}}
>
{count}
</p>
);
},
},
{
key: 'offers',
header: 'Offers',
width: '70px',
cell: (row) => {
const count = offersCountByOrderId[row.id] ?? 0;
return (
<p
style={{
fontWeight: 500,
color: count > 0 ? '#1F8FEB' : '#B6B6C4',
}}
>
{count}
</p>
);
},
},
{
key: 'time',
header: 'Time',
width: '180px',
cell: (row) => <p>{formatTimestamp(row.timestamp)}</p>,
},
{
key: 'action',
header: 'Action',
width: '80px',
align: 'left',
cell: (row) => <CancelActionCell id={row.id} onAfter={onAfter} />,
},
];
}
export function buildApplyTipsColumns({
type,
firstCurrencyName,
secondCurrencyName,
matrixAddresses,
secondAssetUsdPrice,
userOrders,
pairData,
onAfter,
}: BuildApplyTipsColumnsArgs): ColumnDef<ApplyTip>[] {
return [
{
key: 'alias',
header: 'Alias',
width: '180px',
cell: (row) => (
<AliasCell
alias={row.user?.alias}
address={row.user?.address}
matrixAddresses={matrixAddresses}
isInstant={row.isInstant}
/>
),
},
{
key: 'price',
header: <>Price ({secondCurrencyName})</>,
width: '150px',
cell: (row) => <p>{notationToString(row.price)}</p>,
},
{
key: 'quantity',
header: <>Quantity ({firstCurrencyName})</>,
width: '160px',
cell: (row) => <p>{notationToString(row.left)}</p>,
},
{
key: 'total',
header: <>Total ({secondCurrencyName})</>,
width: '180px',
cell: (row) => (
<TotalUsdCell
amount={row.left}
price={row.price}
secondAssetUsdPrice={secondAssetUsdPrice}
/>
),
},
{
key: 'time',
header: 'Time',
width: '180px',
cell: (row) => <p>{formatTimestamp(Number(row.timestamp))}</p>,
},
{
key: 'action',
header: 'Action',
width: type === 'offers' ? '140px' : '90px',
cell: (row) => (
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<RequestActionCell
type={type === 'suitables' ? 'request' : 'accept'}
row={row}
pairData={pairData}
connectedOrder={userOrders.find((o) => o.id === row.connected_order_id)}
onAfter={onAfter}
/>
{type === 'offers' && (
<CancelActionCell
type="reject"
id={row.connected_order_id}
onAfter={onAfter}
/>
)}
</div>
),
},
];
}
export function buildMyRequestsColumns({
firstCurrencyName,
secondCurrencyName,
matrixAddresses,
secondAssetUsdPrice,
onAfter,
}: BuildMyRequestsColumnsArgs): ColumnDef<UserPendingType>[] {
return [
{
key: 'alias',
header: 'Alias',
width: '180px',
cell: (row) => (
<AliasCell
alias={row.finalizer?.alias}
address={row.finalizer?.address}
matrixAddresses={matrixAddresses}
/>
),
},
{
key: 'price',
header: <>Price ({secondCurrencyName})</>,
width: '150px',
cell: (row) => <p>{notationToString(row.price)}</p>,
},
{
key: 'quantity',
header: <>Quantity ({firstCurrencyName})</>,
width: '160px',
cell: (row) => <p>{notationToString(row.amount)}</p>,
},
{
key: 'total',
header: <>Total ({secondCurrencyName})</>,
width: '180px',
cell: (row) => (
<TotalUsdCell
amount={row.amount}
price={row.price}
secondAssetUsdPrice={secondAssetUsdPrice}
/>
),
},
{
key: 'time',
header: 'Time',
width: '180px',
cell: (row) => <p>{formatTimestamp(row.timestamp)}</p>,
},
{
key: 'action',
header: 'Action',
width: '80px',
align: 'left',
cell: (row) => (
<CancelActionCell
id={String(row.creator === 'sell' ? row.sell_order_id : row.buy_order_id)}
onAfter={onAfter}
/>
),
},
];
}
export function buildOrderHistoryColumns({
firstCurrencyName,
secondCurrencyName,
secondAssetUsdPrice,
}: BuildOrderHistoryColumnsArgs): ColumnDef<UserOrderData>[] {
return [
{
key: 'pair',
header: 'Pair',
width: '120px',
cell: (row) => (
<p
style={
{
'--direction-color': row.type === 'buy' ? '#16D1D6' : '#FF6767',
} as React.CSSProperties
}
>
{firstCurrencyName}/{secondCurrencyName}
</p>
),
},
{
key: 'direction',
header: 'Direction',
width: '110px',
cell: (row) => (
<p
style={{
color: row.type === 'buy' ? '#16D1D6' : '#FF6767',
textTransform: 'capitalize',
}}
>
{row.type}
</p>
),
},
{
key: 'price',
header: <>Price ({secondCurrencyName})</>,
width: '150px',
cell: (row) => <p>{notationToString(row.price)}</p>,
},
{
key: 'quantity',
header: <>Quantity ({firstCurrencyName})</>,
width: '160px',
cell: (row) => <p>{notationToString(row.left)}</p>,
},
{
key: 'total',
header: <>Total ({secondCurrencyName})</>,
width: '180px',
cell: (row) => (
<TotalUsdCell
amount={row.left}
price={row.price}
secondAssetUsdPrice={secondAssetUsdPrice}
/>
),
},
{
key: 'time',
header: 'Time',
width: '100px',
cell: (row) => <p>{formatTimestamp(row.timestamp)}</p>,
},
];
}

View file

@ -0,0 +1,38 @@
import MatrixAddress from '@/interfaces/common/MatrixAddress';
import OrderRow from '@/interfaces/common/OrderRow';
import PairData from '@/interfaces/common/PairData';
export interface BuildUserColumnsArgs {
firstCurrencyName: string;
secondCurrencyName: string;
secondAssetUsdPrice?: number;
matchesCountByOrderId: Record<string, number>;
requestsCountByOrderId: Record<string, number>;
offersCountByOrderId: Record<string, number>;
onAfter: () => Promise<void>;
}
export interface BuildApplyTipsColumnsArgs {
type: 'suitables' | 'offers';
firstCurrencyName: string;
secondCurrencyName: string;
matrixAddresses: MatrixAddress[];
secondAssetUsdPrice?: number;
userOrders: OrderRow[];
pairData: PairData | null;
onAfter: () => Promise<void>;
}
export interface BuildMyRequestsColumnsArgs {
firstCurrencyName: string;
secondCurrencyName: string;
secondAssetUsdPrice?: number;
matrixAddresses: MatrixAddress[];
onAfter: () => Promise<void>;
}
export interface BuildOrderHistoryColumnsArgs {
firstCurrencyName: string;
secondCurrencyName: string;
secondAssetUsdPrice?: number;
}

View file

@ -0,0 +1,41 @@
import { useState } from 'react';
import { useAlert } from '@/hook/useAlert';
import { cancelOrder } from '@/utils/methods';
import ActionBtn from '@/components/UI/ActionBtn';
import { CancelActionCellProps } from './types';
export default function CancelActionCell({ type = 'cancel', id, onAfter }: CancelActionCellProps) {
const [loading, setLoading] = useState(false);
const { setAlertState, setAlertSubtitle } = useAlert();
const onClick = async () => {
if (loading) return;
try {
setLoading(true);
const result = await cancelOrder(id);
if (!result.success) {
setAlertState('error');
setAlertSubtitle('Error while cancelling order');
setTimeout(() => {
setAlertState(null);
setAlertSubtitle('');
}, 3000);
return;
}
await onAfter();
} finally {
setLoading(false);
}
};
return (
<ActionBtn
variant={type === 'reject' ? 'danger' : 'primary'}
disabled={loading}
onClick={() => onClick()}
>
{type === 'cancel' ? 'Cancel' : 'Reject'}
</ActionBtn>
);
}

View file

@ -0,0 +1,5 @@
export interface CancelActionCellProps {
type?: 'cancel' | 'reject';
id: string;
onAfter: () => Promise<void>;
}

View file

@ -0,0 +1,46 @@
import React from 'react';
import { classes, formatTimestamp, notationToString } from '@/utils/utils';
import { OrderGroupHeaderProps } from './types';
import styles from './styles.module.scss';
export default function OrderGroupHeader({
order,
firstCurrencyName,
secondCurrencyName,
}: OrderGroupHeaderProps) {
if (!order) return;
return (
<div className={classes(styles.header, styles[order.type])}>
<div className={styles.header__item}>
<p className={styles.header__label}>For order</p>
<p className={classes(styles.header__value, styles.bold)}>
{firstCurrencyName}/{secondCurrencyName}
</p>
<p className={styles.header__type}>{order.type}</p>
</div>
<div className={styles.header__item}>
<p className={styles.header__label}>Quantity</p>
<p className={styles.header__value}>
{notationToString(order.left)} {firstCurrencyName}
</p>
</div>
<div className={styles.header__item}>
<p className={styles.header__label}>Total</p>
<p className={styles.header__value}>
{notationToString(order.total)} {secondCurrencyName}
</p>
</div>
<div className={styles.header__item}>
<p className={styles.header__value}>{formatTimestamp(order.timestamp)}</p>
</div>
</div>
);
}

View file

@ -0,0 +1,66 @@
.header {
margin-block: 2px;
display: flex;
align-items: center;
gap: 20px;
padding-inline: 12px;
padding-block: 6px;
background-color: var(--table-group-header-bg);
&::before {
content: '';
pointer-events: none;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 14px;
width: 2px;
}
&.buy {
.header__type {
color: #16d1d6;
}
&::before {
background-color: #16d1d6;
}
}
&.sell {
.header__type {
color: #ff6767;
}
&::before {
background-color: #ff6767;
}
}
&__item {
display: flex;
align-items: center;
gap: 8px;
}
&__label,
&__value,
&__type {
font-size: 12px;
font-weight: 400;
&.bold {
margin-inline: 2px;
font-weight: 500;
}
}
&__label {
color: var(--table-th-color);
}
&__type {
text-transform: capitalize;
}
}

View file

@ -0,0 +1,7 @@
import OrderRow from '@/interfaces/common/OrderRow';
export interface OrderGroupHeaderProps {
order?: OrderRow;
firstCurrencyName: string;
secondCurrencyName: string;
}

View file

@ -0,0 +1,124 @@
import Link from 'next/link';
import Decimal from 'decimal.js';
import { useState, useContext } from 'react';
import { Store } from '@/store/store-reducer';
import { useAlert } from '@/hook/useAlert';
import { applyOrder, confirmTransaction } from '@/utils/methods';
import { confirmIonicSwap, ionicSwap } from '@/utils/wallet';
import { updateAutoClosedNotification } from '@/store/actions';
import { notationToString } from '@/utils/utils';
import ActionBtn from '@/components/UI/ActionBtn';
import { RequestActionCellProps } from './types';
export default function RequestActionCell({
type = 'request',
row,
pairData,
onAfter,
connectedOrder,
userOrders,
}: RequestActionCellProps) {
const [loading, setLoading] = useState(false);
const { state, dispatch } = useContext(Store);
const { setAlertState, setAlertSubtitle } = useAlert();
const _connectedOrder =
connectedOrder ?? userOrders?.find((o) => o.id === row.connected_order_id);
const alertErr = (subtitle: string) => {
setAlertState('error');
setAlertSubtitle(subtitle);
setTimeout(() => {
setAlertState(null);
setAlertSubtitle('');
}, 3000);
};
const onClick = async () => {
setLoading(true);
let result: { success: boolean } | null = null;
try {
if (row.id) {
updateAutoClosedNotification(dispatch, [
...state.closed_notifications,
parseInt(String(row.id), 10),
]);
}
if (row.transaction) {
if (!row.hex_raw_proposal) {
alertErr('Invalid transaction data received');
return;
}
const confirmSwapResult = await confirmIonicSwap(row.hex_raw_proposal);
if (confirmSwapResult.data?.error?.code === -7) {
alertErr('Insufficient funds');
return;
}
if (!confirmSwapResult.data?.result) {
alertErr('Companion responded with an error');
return;
}
result = await confirmTransaction(row.id);
} else {
const firstCurrencyId = pairData?.first_currency.asset_id;
const secondCurrencyId = pairData?.second_currency.asset_id;
if (!(firstCurrencyId && secondCurrencyId)) {
alertErr('Invalid transaction data received');
return;
}
if (!_connectedOrder) return;
const leftDecimal = new Decimal(row.left);
const priceDecimal = new Decimal(row.price);
const params = {
destinationAssetID: row.type === 'buy' ? secondCurrencyId : firstCurrencyId,
destinationAssetAmount: notationToString(
row.type === 'buy'
? leftDecimal.mul(priceDecimal).toString()
: leftDecimal.toString(),
),
currentAssetID: row.type === 'buy' ? firstCurrencyId : secondCurrencyId,
currentAssetAmount: notationToString(
row.type === 'buy'
? leftDecimal.toString()
: leftDecimal.mul(priceDecimal).toString(),
),
destinationAddress: row.user.address,
};
const createSwapResult = await ionicSwap(params);
const hex = createSwapResult?.data?.result?.hex_raw_proposal;
if (createSwapResult?.data?.error?.code === -7) {
alertErr('Insufficient funds');
return;
}
if (!hex) {
alertErr('Companion responded with an error');
return;
}
result = await applyOrder({ ...row, hex_raw_proposal: hex });
}
} finally {
setLoading(false);
}
if (!result) return;
if (!result.success) {
alertErr('Server responded with an error');
return;
}
await onAfter();
};
return (
<ActionBtn variant="success" disabled={loading} onClick={() => onClick()}>
{type === 'request' ? 'Request' : 'Accept'}
</ActionBtn>
);
}

View file

@ -0,0 +1,12 @@
import ApplyTip from '@/interfaces/common/ApplyTip';
import OrderRow from '@/interfaces/common/OrderRow';
import PairData from '@/interfaces/common/PairData';
export interface RequestActionCellProps {
type?: 'request' | 'accept';
row: ApplyTip;
pairData: PairData | null;
onAfter: () => Promise<void>;
connectedOrder?: OrderRow;
userOrders?: OrderRow[];
}

View file

@ -0,0 +1,412 @@
import ContentPreloader from '@/components/UI/ContentPreloader/ContentPreloader';
import useUpdateUser from '@/hook/useUpdateUser';
import EmptyMessage from '@/components/UI/EmptyMessage';
import { useContext, useEffect, useMemo, useState } from 'react';
import GenericTable from '@/components/default/GenericTable';
import ActionBtn from '@/components/UI/ActionBtn';
import { getUserOrders, getUserPendings } from '@/utils/methods';
import UserPendingType from '@/interfaces/common/UserPendingType';
import { Store } from '@/store/store-reducer';
import { UserOrderData } from '@/interfaces/responses/orders/GetUserOrdersRes';
import { useAlert } from '@/hook/useAlert';
import Alert from '@/components/UI/Alert/Alert';
import { tabsType } from '@/components/UI/Tabs/types';
import Tabs from '@/components/UI/Tabs';
import { countByKeyRecord, createOrderSorter } from '@/utils/utils';
import ApplyTip from '@/interfaces/common/ApplyTip';
import { useQuerySyncedTab } from '@/hook/useQuerySyncedTab';
import { useMediaQuery } from '@/hook/useMediaQuery';
import { UserOrdersProps } from './types';
import styles from './styles.module.scss';
import {
buildApplyTipsColumns,
buildMyRequestsColumns,
buildOrderHistoryColumns,
buildUserColumns,
} from './columns';
import OrderGroupHeader from './components/OrderGroupHeader';
import UniversalCards from './cards/UniversalCards';
const UserOrders = ({
userOrders,
applyTips,
myOrdersLoading,
handleCancelAllOrders,
orderListRef,
matrixAddresses,
secondAssetUsdPrice,
onAfter,
pairData,
}: UserOrdersProps) => {
const { state } = useContext(Store);
const loggedIn = !!state.wallet?.connected;
const { setAlertState, setAlertSubtitle, alertState, alertSubtitle } = useAlert();
const isSm = useMediaQuery('(max-width: 820px)');
const isMobile = useMediaQuery('(max-width: 580px)');
const fetchUser = useUpdateUser();
const matches = applyTips.filter((s) => !s.transaction);
const offers = applyTips.filter((s) => s.transaction);
const [userRequests, setUserRequests] = useState<UserPendingType[]>([]);
const [ordersHistory, setOrdersHistory] = useState<UserOrderData[]>([]);
const tabsData: tabsType[] = useMemo(
() => [
{
title: 'My Orders',
type: 'opened',
length: userOrders.length,
},
{
title: 'Matches',
type: 'matches',
length: matches.length,
},
{
title: 'My requests',
type: 'requests',
length: userRequests.length,
},
{
title: 'Offers',
type: 'offers',
length: offers.length,
},
{
title: 'History',
type: 'history',
length: ordersHistory.length,
},
],
[
offers.length,
userOrders.length,
matches.length,
userRequests.length,
ordersHistory.length,
],
);
const { active: ordersType, setActiveTab } = useQuerySyncedTab({
tabs: tabsData,
defaultType: 'opened',
queryKey: 'tab',
});
useEffect(() => {
if (!loggedIn) return;
(async () => {
const requestsData = await getUserPendings();
if (requestsData.success) {
setUserRequests(requestsData.data);
}
})();
(async () => {
const result = await getUserOrders();
if (!result.success) {
setAlertState('error');
setAlertSubtitle('Error loading orders data');
await new Promise((resolve) => setTimeout(resolve, 2000));
setAlertState(null);
setAlertSubtitle('');
return;
}
const filteredOrdersHistory = result.data
.filter((s) => s.pair_id === pairData?.id)
.filter((s) => s.status === 'finished');
fetchUser();
setOrdersHistory(filteredOrdersHistory);
})();
}, [userOrders, applyTips]);
const firstCurrencyName = pairData?.first_currency?.name ?? '';
const secondCurrencyName = pairData?.second_currency?.name ?? '';
const matchesCountByOrderId = useMemo(() => {
return countByKeyRecord(matches, (tip) => tip.connected_order_id);
}, [matches]);
const requestsCountByOrderId = useMemo(() => {
return countByKeyRecord(userRequests, (tip) =>
tip.creator === 'sell' ? tip.sell_order_id : tip.buy_order_id,
);
}, [userRequests]);
const offersCountByOrderId = useMemo(() => {
return countByKeyRecord(offers, (tip) => tip.connected_order_id);
}, [offers]);
const columnsOpened = useMemo(
() =>
buildUserColumns({
firstCurrencyName,
secondCurrencyName,
secondAssetUsdPrice,
matchesCountByOrderId,
requestsCountByOrderId,
offersCountByOrderId,
onAfter,
}),
[
firstCurrencyName,
secondCurrencyName,
secondAssetUsdPrice,
matchesCountByOrderId,
offersCountByOrderId,
requestsCountByOrderId,
onAfter,
],
);
const columnsSuitables = useMemo(
() =>
buildApplyTipsColumns({
type: 'suitables',
firstCurrencyName,
secondCurrencyName,
matrixAddresses,
secondAssetUsdPrice,
userOrders,
pairData,
onAfter,
}),
[
firstCurrencyName,
secondCurrencyName,
matrixAddresses,
secondAssetUsdPrice,
userOrders,
pairData,
onAfter,
],
);
const columnsMyRequests = useMemo(
() =>
buildMyRequestsColumns({
firstCurrencyName,
secondCurrencyName,
matrixAddresses,
onAfter,
}),
[firstCurrencyName, secondCurrencyName, onAfter, matrixAddresses],
);
const columnsOffers = useMemo(
() =>
buildApplyTipsColumns({
type: 'offers',
firstCurrencyName,
secondCurrencyName,
matrixAddresses,
secondAssetUsdPrice,
userOrders,
pairData,
onAfter,
}),
[
firstCurrencyName,
secondCurrencyName,
matrixAddresses,
secondAssetUsdPrice,
userOrders,
pairData,
onAfter,
],
);
const columnsOrderHistory = useMemo(
() =>
buildOrderHistoryColumns({
firstCurrencyName,
secondCurrencyName,
secondAssetUsdPrice,
}),
[firstCurrencyName, secondCurrencyName, secondAssetUsdPrice],
);
const sortMatches = createOrderSorter<ApplyTip>({
getPrice: (e) => e.price,
getSide: (e) => e.type,
});
const renderOrders = () => {
switch (ordersType.type) {
case 'opened':
return !isSm ? (
<GenericTable
className={styles.userOrders__body}
columns={columnsOpened}
data={userOrders}
getRowKey={(r) => r.id}
emptyMessage="No orders"
/>
) : (
<UniversalCards
type="orders"
data={userOrders}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
secondAssetUsdPrice={secondAssetUsdPrice}
matchesCountByOrderId={matchesCountByOrderId}
offersCountByOrderId={offersCountByOrderId}
requestsCountByOrderId={requestsCountByOrderId}
onAfter={onAfter}
/>
);
case 'matches':
return !isSm ? (
<GenericTable
className={styles.userOrders__body}
columns={columnsSuitables}
data={matches.sort(sortMatches)}
getRowKey={(r) => r.id}
emptyMessage="No suitables"
groupBy={(r) => r.connected_order_id}
renderGroupHeader={({ groupKey }) => (
<OrderGroupHeader
order={userOrders.find((o) => String(o.id) === String(groupKey))}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
/>
)}
/>
) : (
<UniversalCards
type="matches"
data={matches.sort(sortMatches)}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
secondAssetUsdPrice={secondAssetUsdPrice}
onAfter={onAfter}
matrixAddresses={matrixAddresses}
pairData={pairData}
userOrders={userOrders}
/>
);
case 'requests':
return !isSm ? (
<GenericTable
className={styles.userOrders__body}
columns={columnsMyRequests}
data={userRequests}
getRowKey={(r) => r.id}
emptyMessage="No requests"
groupBy={(r) => (r.creator === 'sell' ? r.sell_order_id : r.buy_order_id)}
renderGroupHeader={({ groupKey }) => (
<OrderGroupHeader
order={userOrders.find((o) => String(o.id) === String(groupKey))}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
/>
)}
/>
) : (
<UniversalCards
type="requests"
data={userRequests}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
secondAssetUsdPrice={secondAssetUsdPrice}
onAfter={onAfter}
matrixAddresses={matrixAddresses}
/>
);
case 'offers':
return !isSm ? (
<GenericTable
className={styles.userOrders__body}
columns={columnsOffers}
data={offers}
getRowKey={(r) => r.id}
emptyMessage="No offers"
groupBy={(r) => r.connected_order_id}
renderGroupHeader={({ groupKey }) => (
<OrderGroupHeader
order={userOrders.find((o) => String(o.id) === String(groupKey))}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
/>
)}
/>
) : (
<UniversalCards
type="offers"
data={offers}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
secondAssetUsdPrice={secondAssetUsdPrice}
onAfter={onAfter}
matrixAddresses={matrixAddresses}
pairData={pairData}
userOrders={userOrders}
/>
);
case 'history':
return !isSm ? (
<GenericTable
className={styles.userOrders__body}
columns={columnsOrderHistory}
data={ordersHistory}
getRowKey={(r) => r.id}
emptyMessage="No data"
/>
) : (
<UniversalCards
type="history"
data={ordersHistory}
firstCurrencyName={firstCurrencyName}
secondCurrencyName={secondCurrencyName}
secondAssetUsdPrice={secondAssetUsdPrice}
onAfter={onAfter}
/>
);
default:
return null;
}
};
return (
<>
<div ref={orderListRef} className={styles.userOrders}>
<div className={styles.userOrders__header}>
<Tabs
type={isMobile ? 'button' : 'tab'}
data={tabsData}
value={ordersType}
setValue={(t) => setActiveTab(t.type)}
/>
{ordersType?.type === 'opened' && userOrders.length > 0 && (
<ActionBtn
className={styles.userOrders__header_btn}
onClick={handleCancelAllOrders}
>
Cancel all
</ActionBtn>
)}
</div>
{!myOrdersLoading && loggedIn && renderOrders()}
{myOrdersLoading && loggedIn && <ContentPreloader style={{ marginTop: 40 }} />}
{!loggedIn && <EmptyMessage text="Connect wallet to see your orders" />}
</div>
{alertState && (
<Alert
type={alertState}
subtitle={alertSubtitle || ''}
close={() => setAlertState(null)}
/>
)}
</>
);
};
export default UserOrders;

View file

@ -0,0 +1,118 @@
.userOrders {
position: relative;
width: 100%;
background: var(--window-bg-color);
border: 1px solid var(--delimiter-color);
border-radius: 10px;
padding: 1px;
min-height: 310px;
&__body {
position: relative;
padding-top: 5px;
height: 260px;
overflow: auto;
}
&__header {
position: relative;
padding-top: 14px;
margin-inline: 14px;
padding-bottom: 0;
&_btn {
position: absolute;
right: 0px;
top: 7px;
z-index: 2;
}
}
table {
width: 100%;
border-spacing: 0;
border-collapse: collapse;
table-layout: fixed;
thead {
th {
min-width: 100px;
font-size: 11px;
font-weight: 700;
text-align: left;
color: var(--table-th-color);
padding: 6px 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:last-child {
text-align: right;
min-width: 50px;
}
}
}
tbody {
td {
position: relative;
> p {
width: 100%;
font-size: 12px;
font-weight: 400;
&:first-child {
&::before {
content: '';
pointer-events: none;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 50%;
width: 2px;
background-color: var(--direction-color);
}
}
> span {
line-height: 1;
color: var(--font-dimmed-color);
font-size: 11px;
}
}
}
}
}
}
@media screen and (max-width: 720px) {
.userOrders {
&__header {
&_btn {
display: none;
}
}
}
}
@media screen and (max-width: 580px) {
.userOrders {
margin-top: 20px;
background: transparent;
border: none;
padding: 0;
min-height: auto;
&__header,
&__body {
padding: 0;
margin-inline: 0;
}
&__header {
margin-bottom: 10px;
}
}
}

View file

@ -0,0 +1,18 @@
import ApplyTip from '@/interfaces/common/ApplyTip';
import MatrixAddress from '@/interfaces/common/MatrixAddress';
import OrderRow from '@/interfaces/common/OrderRow';
import PairData from '@/interfaces/common/PairData';
import { ForwardedRef } from 'react';
export type OrderType = 'opened' | 'suitable' | 'requests' | 'offers' | 'history';
export interface UserOrdersProps {
orderListRef: ForwardedRef<HTMLDivElement>;
userOrders: OrderRow[];
applyTips: ApplyTip[];
myOrdersLoading: boolean;
handleCancelAllOrders: () => void;
secondAssetUsdPrice: number | undefined;
matrixAddresses: MatrixAddress[];
pairData: PairData | null;
onAfter: () => Promise<void>;
}

30
src/constants/index.ts Normal file
View file

@ -0,0 +1,30 @@
import PeriodState from '@/interfaces/states/pages/dex/trading/InputPanelItem/PeriodState';
import SelectValue from '@/interfaces/states/pages/dex/trading/InputPanelItem/SelectValue';
export const periods: PeriodState[] = [
// { name: '1s', code: '1sec' },
{ name: '1m', code: '1min' },
{ name: '5m', code: '5min' },
{ name: '15m', code: '15min' },
{ name: '30m', code: '30min' },
{ name: '1h', code: '1h' },
{ name: '4h', code: '4h' },
{ name: '1d', code: '1d' },
{ name: '1w', code: '1w' },
{ name: '1M', code: '1m' },
];
export const buySellValues: SelectValue[] = [
{
name: 'All',
code: 'all',
},
{
name: 'Buy',
code: 'buy',
},
{
name: 'Sell',
code: 'sell',
},
];

22
src/hook/useAlert.ts Normal file
View file

@ -0,0 +1,22 @@
import AlertType from '@/interfaces/common/AlertType';
import { Store } from '@/store/store-reducer';
import { useContext } from 'react';
export const useAlert = () => {
const { state, dispatch } = useContext(Store);
const setAlertState = (state: AlertType) => {
dispatch({ type: 'ALERT_STATE_UPDATED', payload: state });
};
const setAlertSubtitle = (subtitle: string) => {
dispatch({ type: 'ALERT_SUBTITLE_UPDATED', payload: subtitle });
};
return {
alertState: state.alertState,
alertSubtitle: state.alertSubtitle,
setAlertState,
setAlertSubtitle,
};
};

View file

@ -0,0 +1,30 @@
import { PageOrderData } from '@/interfaces/responses/orders/GetOrdersPageRes';
import SelectValue from '@/interfaces/states/pages/dex/trading/InputPanelItem/SelectValue';
import { Store } from '@/store/store-reducer';
import { useContext } from 'react';
interface useFilteredDataParams {
ordersHistory: PageOrderData[];
ordersBuySell: SelectValue;
}
const useFilteredData = ({ ordersHistory, ordersBuySell }: useFilteredDataParams) => {
const { state } = useContext(Store);
const filteredOrdersHistory = ordersHistory
?.filter((e) => (ordersBuySell.code === 'all' ? e : e.type === ordersBuySell.code))
?.filter((e) => e.user.address !== state.wallet?.address)
?.filter((e) => parseFloat(e.left.toString()) !== 0)
?.sort((a, b) => {
if (ordersBuySell.code === 'buy') {
return parseFloat(b.price.toString()) - parseFloat(a.price.toString());
}
return parseFloat(a.price.toString()) - parseFloat(b.price.toString());
});
return {
filteredOrdersHistory,
};
};
export default useFilteredData;

View file

@ -0,0 +1,25 @@
import MatrixAddress from '@/interfaces/common/MatrixAddress';
import { PageOrderData } from '@/interfaces/responses/orders/GetOrdersPageRes';
import { getMatrixAddresses } from '@/utils/methods';
import { useEffect, useState } from 'react';
const useMatrixAddresses = (ordersHistory: PageOrderData[]) => {
const [matrixAddresses, setMatrixAddresses] = useState<MatrixAddress[]>([]);
useEffect(() => {
const fetchConnections = async () => {
const filteredAddresses = ordersHistory?.map((e) => e?.user?.address);
if (!filteredAddresses.length) return;
const data = await getMatrixAddresses(filteredAddresses);
setMatrixAddresses(data.addresses);
};
fetchConnections();
}, [ordersHistory]);
return matrixAddresses;
};
export default useMatrixAddresses;

19
src/hook/useMediaQuery.ts Normal file
View file

@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const media = window.matchMedia(query);
const listener = () => setMatches(media.matches);
setMatches(media.matches);
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}, [query]);
return matches;
}

21
src/hook/useMouseLeave.ts Normal file
View file

@ -0,0 +1,21 @@
import { ForwardedRef, useEffect } from 'react';
const useMouseLeave = (ref: ForwardedRef<HTMLElement>, callbackFn: () => void) => {
useEffect(() => {
const targetEl = (event: MouseEvent) => {
if (ref && typeof ref !== 'function' && ref.current) {
if (ref?.current && !ref?.current.contains(event.target as Node)) {
callbackFn();
}
}
};
window.addEventListener('mousemove', targetEl);
return () => {
window.removeEventListener('mousemove', targetEl);
};
}, []);
};
export default useMouseLeave;

110
src/hook/useOrdereForm.ts Normal file
View file

@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import Decimal from 'decimal.js';
import PairData from '@/interfaces/common/PairData';
import OrderFormOutput from '@/interfaces/common/orderFormOutput';
import { handleInputChange } from '@/utils/handleInputChange';
interface UseOrderFormParams {
pairData: PairData | null;
balance: string | undefined;
assetsRates: Map<string, number>;
}
function clamp12(str: string) {
try {
return new Decimal(str || '0').toDecimalPlaces(12, Decimal.ROUND_DOWN).toString();
} catch {
return '0';
}
}
export function useOrderForm({
pairData,
balance,
assetsRates,
}: UseOrderFormParams): OrderFormOutput {
const [price, setPrice] = useState('');
const [amount, setAmount] = useState('');
const [total, setTotal] = useState('');
const [priceValid, setPriceValid] = useState(false);
const [amountValid, setAmountValid] = useState(false);
const [totalValid, setTotalValid] = useState(false);
const [totalUsd, setTotalUsd] = useState<string | undefined>(undefined);
const [rangeInputValue, setRangeInputValue] = useState('50');
const priceDP = pairData?.second_currency?.asset_info?.decimal_point || 12;
const amountDP = pairData?.first_currency?.asset_info?.decimal_point || 12;
useEffect(() => {
try {
const totalDecimal = new Decimal(total);
const zanoPrice = assetsRates.get(pairData?.second_currency?.asset_id || '');
setTotalUsd(zanoPrice ? totalDecimal.mul(zanoPrice).toFixed(2) : undefined);
} catch (err) {
setTotalUsd(undefined);
}
}, [total, assetsRates, pairData?.second_currency?.asset_id]);
function onPriceChange(inputValue: string) {
handleInputChange({
inputValue,
priceOrAmount: 'price',
otherValue: amount,
thisDP: priceDP,
totalDP: priceDP,
setThisState: setPrice,
setTotalState: (v: string) => setTotal(clamp12(v)),
setThisValid: setPriceValid,
setTotalValid,
});
}
function onAmountChange(inputValue: string) {
handleInputChange({
inputValue,
priceOrAmount: 'amount',
otherValue: price,
thisDP: amountDP,
totalDP: priceDP,
setThisState: setAmount,
setTotalState: (v: string) => setTotal(clamp12(v)),
setThisValid: setAmountValid,
setTotalValid,
balance,
setRangeInputValue,
});
}
function resetForm() {
setPrice('');
setAmount('');
setTotal('');
setPriceValid(false);
setAmountValid(false);
setTotalValid(false);
setRangeInputValue('50');
}
return {
price,
amount,
total,
priceValid,
amountValid,
totalValid,
totalUsd,
rangeInputValue,
setRangeInputValue,
onPriceChange,
onAmountChange,
resetForm,
setTotal,
setPrice,
setAmount,
setPriceValid,
setAmountValid,
setTotalValid,
};
}

View file

@ -0,0 +1,71 @@
import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
type TabType = string;
type TabItem<T extends TabType> = {
title: string;
type: T;
length?: number;
};
type Options<T extends TabType> = {
tabs: TabItem<T>[];
queryKey?: string;
defaultType?: T;
replace?: boolean;
};
export function useQuerySyncedTab<T extends TabType>({
tabs,
queryKey = 'tab',
defaultType,
replace = true,
}: Options<T>) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const urlValue = searchParams.get(queryKey) as T | null;
const initialTab = useMemo(() => {
const fallback = (defaultType ?? tabs[0]?.type) as T;
if (!urlValue) return tabs.find((t) => t.type === fallback) ?? tabs[0];
return (
tabs.find((t) => t.type === urlValue) ??
tabs.find((t) => t.type === fallback) ??
tabs[0]
);
}, [tabs, urlValue, defaultType]);
const [active, setActive] = useState<TabItem<T>>(initialTab);
useEffect(() => {
setActive(initialTab);
}, [initialTab]);
const setActiveTab = (next: TabItem<T> | T) => {
const nextType = (typeof next === 'string' ? next : next.type) as T;
const found = tabs.find((t) => t.type === nextType);
if (found) setActive(found);
const params = new URLSearchParams(searchParams.toString());
const def = (defaultType ?? tabs[0]?.type) as T;
if (nextType === def) {
params.delete(queryKey);
} else {
params.set(queryKey, nextType);
}
const url = params.toString() ? `${pathname}?${params}` : pathname;
if (replace) {
router.replace(url, { scroll: false });
} else {
router.push(url, { scroll: false });
}
};
return { active, setActiveTab };
}

20
src/hook/useScroll.ts Normal file
View file

@ -0,0 +1,20 @@
import { useCallback, useRef } from 'react';
function useScroll<T extends HTMLElement>() {
const elementRef = useRef<T | null>(null);
const scrollToElement = useCallback((options?: ScrollIntoViewOptions) => {
if (elementRef.current) {
elementRef.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
...options,
});
}
}, []);
return { elementRef, scrollToElement };
}
export default useScroll;

View file

@ -0,0 +1,85 @@
import ApplyTip from '@/interfaces/common/ApplyTip';
import OrderRow from '@/interfaces/common/OrderRow';
import { PageOrderData } from '@/interfaces/responses/orders/GetOrdersPageRes';
import { PairStats } from '@/interfaces/responses/orders/GetPairStatsRes';
import { getUserOrdersPage } from '@/utils/methods';
import socket from '@/utils/socket';
import { useRouter } from 'next/router';
import { Dispatch, SetStateAction, useEffect } from 'react';
interface useSocketListenersParams {
setUserOrders: Dispatch<SetStateAction<OrderRow[]>>;
setApplyTips: Dispatch<SetStateAction<ApplyTip[]>>;
setPairStats: Dispatch<SetStateAction<PairStats | null>>;
setOrdersHistory: Dispatch<SetStateAction<PageOrderData[]>>;
ordersHistory: PageOrderData[];
updateOrders: () => Promise<void>;
}
export const useSocketListeners = ({
setUserOrders,
setApplyTips,
setPairStats,
setOrdersHistory,
ordersHistory,
updateOrders,
}: useSocketListenersParams) => {
const router = useRouter();
const pairId = typeof router.query.id === 'string' ? router.query.id : '';
async function socketUpdateOrders() {
const result = await getUserOrdersPage(pairId);
if (result.success) {
setUserOrders(result?.data?.orders || []);
setApplyTips(result?.data?.applyTips || []);
}
}
useEffect(() => {
socket.emit('in-trading', { id: router.query.id });
return () => {
socket.emit('out-trading', { id: router.query.id });
};
}, []);
useEffect(() => {
socket.on('new-order', async (data) => {
setOrdersHistory([data.orderData, ...ordersHistory]);
await socketUpdateOrders();
});
socket.on('delete-order', async () => {
await updateOrders();
await socketUpdateOrders();
});
return () => {
socket.off('new-order');
socket.off('delete-order');
};
}, [ordersHistory]);
useEffect(() => {
function onUpdateStats({ pairStats }: { pairStats: PairStats }) {
setPairStats(pairStats);
}
socket.on('update-pair-stats', onUpdateStats);
return () => {
socket.off('update-pair-stats', onUpdateStats);
};
}, []);
useEffect(() => {
socket.on('update-orders', async () => {
await socketUpdateOrders();
});
return () => {
socket.off('update-orders');
};
}, []);
};

62
src/hook/useTradeInit.ts Normal file
View file

@ -0,0 +1,62 @@
import PairData from '@/interfaces/common/PairData';
import { Store } from '@/store/store-reducer';
import { useContext } from 'react';
import Decimal from 'decimal.js';
import { PairStats } from '@/interfaces/responses/orders/GetPairStatsRes';
import { ZANO_ASSET_ID } from '@/utils/utils';
import { useOrderForm } from './useOrdereForm';
interface useTradeInitParams {
pairData: PairData | null;
pairStats: PairStats | null;
}
const useTradeInit = ({ pairData, pairStats }: useTradeInitParams) => {
const { state } = useContext(Store);
const currencyNames = {
firstCurrencyName: pairData?.first_currency?.name || '',
secondCurrencyName: pairData?.second_currency?.name || '',
};
const firstCurrencyAssetID = pairData?.first_currency?.asset_id;
const assets = state.wallet?.connected ? state.wallet?.assets || [] : [];
const balance = assets.find((e) => e.assetId === firstCurrencyAssetID)?.balance;
const zanoBalance = assets.find((e) => e.assetId === ZANO_ASSET_ID)?.balance || 0;
const firstAssetId = pairData ? pairData.first_currency?.asset_id : undefined;
const secondAssetId = pairData ? pairData.second_currency?.asset_id : undefined;
const firstAssetLink = firstAssetId
? `https://explorer.zano.org/assets?asset_id=${encodeURIComponent(firstAssetId)}`
: undefined;
const secondAssetLink = secondAssetId
? `https://explorer.zano.org/assets?asset_id=${encodeURIComponent(secondAssetId)}`
: undefined;
const secondAssetUsdPrice = state.assetsRates.get(secondAssetId || '');
const pairRateUsd =
pairStats?.rate !== undefined && secondAssetUsdPrice !== undefined
? new Decimal(pairStats.rate).mul(secondAssetUsdPrice).toFixed(2)
: undefined;
const orderForm = useOrderForm({
pairData,
balance,
assetsRates: state.assetsRates,
});
return {
currencyNames,
firstAssetLink,
secondAssetLink,
secondAssetUsdPrice,
balance,
zanoBalance,
orderForm,
pairRateUsd,
};
};
export default useTradeInit;

143
src/hook/useTradingData.ts Normal file
View file

@ -0,0 +1,143 @@
import {
getCandles,
getOrdersPage,
getPair,
getPairStats,
getUserOrdersPage,
getTrades,
} from '@/utils/methods';
import useUpdateUser from '@/hook/useUpdateUser';
import { Dispatch, SetStateAction, useContext, useEffect, useState } from 'react';
import CandleRow from '@/interfaces/common/CandleRow';
import { PageOrderData } from '@/interfaces/responses/orders/GetOrdersPageRes';
import { Trade } from '@/interfaces/responses/trades/GetTradeRes';
import PairData from '@/interfaces/common/PairData';
import { PairStats } from '@/interfaces/responses/orders/GetPairStatsRes';
import OrderRow from '@/interfaces/common/OrderRow';
import ApplyTip from '@/interfaces/common/ApplyTip';
import { useRouter } from 'next/router';
import PeriodState from '@/interfaces/states/pages/dex/trading/InputPanelItem/PeriodState';
import { Store } from '@/store/store-reducer';
interface UseTradingDataParams {
periodsState: PeriodState;
setCandles: Dispatch<SetStateAction<CandleRow[]>>;
setOrdersHistory: Dispatch<SetStateAction<PageOrderData[]>>;
setTrades: Dispatch<SetStateAction<Trade[]>>;
setPairData: Dispatch<SetStateAction<PairData | null>>;
setPairStats: Dispatch<SetStateAction<PairStats | null>>;
setUserOrders: Dispatch<SetStateAction<OrderRow[]>>;
setApplyTips: Dispatch<SetStateAction<ApplyTip[]>>;
setMyOrdersLoading: Dispatch<SetStateAction<boolean>>;
}
export function useTradingData({
periodsState,
setCandles,
setOrdersHistory,
setTrades,
setPairData,
setPairStats,
setUserOrders,
setApplyTips,
setMyOrdersLoading,
}: UseTradingDataParams) {
const { state } = useContext(Store);
const fetchUser = useUpdateUser();
const router = useRouter();
const [candlesLoaded, setCandlesLoaded] = useState(false);
const [ordersLoading, setOrdersLoading] = useState(true);
const [tradesLoading, setTradesLoading] = useState(true);
const pairId = typeof router.query.id === 'string' ? router.query.id : '';
const loggedIn = !!state.wallet?.connected;
async function fetchCandles() {
setCandlesLoaded(false);
setCandles([]);
const result = await getCandles(pairId, periodsState.code);
if (result.success) {
setCandles(result.data);
} else {
setCandles([]);
}
setCandlesLoaded(true);
}
async function updateOrders() {
const result = await getOrdersPage(pairId);
if (!result.success) return;
setOrdersHistory(result?.data || []);
setOrdersLoading(false);
}
async function updateUserOrders() {
setMyOrdersLoading(true);
const result = await getUserOrdersPage(pairId);
await fetchUser();
if (!result.success) return;
setUserOrders(result?.data?.orders || []);
setApplyTips(result?.data?.applyTips || []);
setMyOrdersLoading(false);
}
async function fetchTrades() {
// setTradesLoading(true);
const result = await getTrades(pairId);
if (result.success) {
setTrades(result.data);
}
setTradesLoading(false);
}
async function fetchPairStats() {
const result = await getPairStats(pairId);
if (!result.success) return;
setPairStats(result.data);
}
async function getPairData() {
const result = await getPair(pairId);
if (!result.success) {
router.push('/404');
return;
}
setPairData(result.data);
}
useEffect(() => {
fetchPairStats();
getPairData();
updateOrders();
}, []);
useEffect(() => {
fetchCandles();
}, [periodsState]);
useEffect(() => {
(async () => {
await fetchTrades();
})();
}, [pairId]);
useEffect(() => {
if (!loggedIn) return;
setUserOrders([]);
updateUserOrders();
}, [state.wallet?.connected && state.wallet?.address]);
return {
fetchCandles,
updateOrders,
updateUserOrders,
fetchTrades,
fetchPairStats,
getPairData,
candlesLoaded,
ordersLoading,
tradesLoading,
};
}

View file

@ -1,6 +1,7 @@
import { Dispatch } from 'react';
import { GetUserResData } from '../responses/user/GetUserRes';
import { GetConfigResData } from '../responses/config/GetConfigRes';
import AlertType from './AlertType';
export interface Asset {
name: string;
@ -50,6 +51,8 @@ interface ContextState {
offers: number;
};
closed_notifications: number[];
alertState: AlertType;
alertSubtitle: string;
}
type ContextAction =
@ -75,6 +78,14 @@ type ContextAction =
| {
type: 'CLOSED_NOTIFICATIONS_UPDATED';
payload: number[];
}
| {
type: 'ALERT_STATE_UPDATED';
payload: AlertType;
}
| {
type: 'ALERT_SUBTITLE_UPDATED';
payload: string;
};
interface ContextValue {

View file

@ -0,0 +1,6 @@
interface MatrixAddress {
address: string;
registered: boolean;
}
export default MatrixAddress;

View file

@ -1,3 +1,3 @@
type Period = '1h' | '1d' | '1w' | '1m';
type Period = '1sec' | '1min' | '5min' | '15min' | '30min' | '1h' | '4h' | '1d' | '1w' | '1m';
export default Period;

View file

@ -0,0 +1,19 @@
interface UserPendingType {
id: number;
amount: string;
price: string;
finalizer: {
address: string;
alias: string;
id: number;
order_id: number;
};
buy_order_id: number;
sell_order_id: number;
creator: 'sell' | 'buy';
hex_raw_proposal: string;
status: string;
timestamp: string;
}
export default UserPendingType;

Some files were not shown because too many files have changed in this diff Show more