Merge pull request #21 from jejolare-dev/staging

fix: input decimals & add scroll center to orders
This commit is contained in:
Dmitrii Kolpakov 2025-09-13 22:18:24 +07:00 committed by GitHub
commit 7c925df103
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 216 additions and 133 deletions

View file

@ -19,8 +19,8 @@ export default function GenericTable<T>(props: GenericTableProps<T>) {
renderGroupHeader,
sortGroups,
responsive,
scrollRef,
} = props;
const isMatch = useMediaQuery(responsive?.query ?? '');
const mediaActive = !!responsive?.query && isMatch;
@ -34,7 +34,7 @@ export default function GenericTable<T>(props: GenericTableProps<T>) {
if (mediaActive && responsive?.alignOverride) {
cols = cols.map((c) => {
const ov = responsive.alignOverride![c.key];
const ov = responsive.alignOverride?.[c.key];
return ov ? { ...c, align: ov } : c;
});
}
@ -60,7 +60,11 @@ export default function GenericTable<T>(props: GenericTableProps<T>) {
return (
<div className={className}>
{data.length > 0 ? (
<div className="orders-scroll" style={{ maxHeight: '100%', overflowY: 'auto' }}>
<div
ref={scrollRef}
className="orders-scroll"
style={{ maxHeight: '100%', overflowY: 'auto' }}
>
<table
className={tableClassName}
style={{

View file

@ -38,4 +38,5 @@ export type GenericTableProps<T> = {
alignOverride?: Record<string, 'left' | 'center' | 'right'>;
tableLayout?: 'auto' | 'fixed';
};
scrollRef?: React.RefObject<HTMLDivElement>;
};

View file

@ -69,12 +69,14 @@ function InputPanelItem(props: InputPanelItemProps) {
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);
amount.lessThan(1000000000) &&
total.greaterThan(0);
if (!isFull) return;
@ -123,6 +125,7 @@ function InputPanelItem(props: InputPanelItemProps) {
const buttonText = creatingState ? 'Creating...' : 'Create Order';
const isButtonDisabled = !priceValid || !amountValid || !totalValid || creatingState;
const showTotalError = priceState !== '' && amountState !== '' && !totalValid;
return (
<div className={styles.inputPanel}>
@ -213,12 +216,12 @@ function InputPanelItem(props: InputPanelItemProps) {
<div className={styles.inputPanel__body_total}>
<LabeledInput
value={notationToString(totalState)}
value={amountState && priceState && notationToString(totalState)}
setValue={() => undefined}
currency={secondCurrencyName}
label="Total"
readonly={true}
invalid={!!totalState && !totalValid}
invalid={showTotalError}
/>
<div className={classes(styles.inputPanel__body_labels, styles.mobileWrap)}>

View file

@ -1,4 +1,4 @@
import React, { useMemo, useRef, useState } from 'react';
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
classes,
createOrderSorter,
@ -35,32 +35,92 @@ const tabsData: tabsType[] = [
const OrdersPool = (props: OrdersPoolProps) => {
const {
ordersBuySell,
OrdersHistory,
setOrdersBuySell,
currencyNames,
ordersLoading,
ordersHistory,
filteredOrdersHistory,
secondAssetUsdPrice,
takeOrderClick,
trades,
tradesLoading,
} = props;
const ordersInfoRef = useRef<HTMLTableSectionElement | null>(null);
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 { maxBuyLeftValue, maxSellLeftValue } = OrdersHistory.reduce(
(acc, order) => {
const left = parseFloat(String(order.left)) || 0;
if (order.type === 'buy') acc.maxBuyLeftValue = Math.max(acc.maxBuyLeftValue, left);
if (order.type === 'sell') acc.maxSellLeftValue = Math.max(acc.maxSellLeftValue, left);
return acc;
},
{ maxBuyLeftValue: 0, maxSellLeftValue: 0 },
);
const totalLeft = maxBuyLeftValue + maxSellLeftValue;
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 });
@ -84,6 +144,36 @@ const OrdersPool = (props: OrdersPoolProps) => {
[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.scrollTo({
top: scrollTop,
behavior: 'smooth',
});
} else {
parent.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}, [ordersLoading, filteredOrdersHistory.length, ordersBuySell.code]);
const sortedTrades = createOrderSorter<PageOrderData>({
getPrice: (e) => e.price,
getSide: (e) => e.type,
@ -104,33 +194,42 @@ const OrdersPool = (props: OrdersPoolProps) => {
columns={ordersPool}
data={filteredOrdersHistory.sort(sortedTrades)}
getRowKey={(r) => r.id}
getRowProps={(row) => ({
className: styles[row.type],
style: {
'--precentage': `${(
(parseFloat(String(row.left)) /
(row.type === 'buy'
? maxBuyLeftValue
: maxSellLeftValue)) *
100
).toFixed(2)}%`,
} 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);
},
})}
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'],
@ -213,63 +312,24 @@ const OrdersPool = (props: OrdersPoolProps) => {
<div className={styles.ordersPool__content}>
{renderTable()}
{currentOrder.type === 'orders' &&
!ordersLoading &&
totalLeft > 0 &&
(() => {
const buy = new Decimal(maxBuyLeftValue || 0);
const sell = new Decimal(maxSellLeftValue || 0);
const total = new Decimal(totalLeft);
{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>
let buyPct = total.gt(0) ? buy.mul(100).div(total) : new Decimal(0);
let sellPct = total.gt(0) ? sell.mul(100).div(total) : new Decimal(0);
if (buy.isZero() && sell.gt(0)) {
buyPct = new Decimal(0);
sellPct = new Decimal(100);
} else if (sell.isZero() && buy.gt(0)) {
sellPct = new Decimal(0);
buyPct = new Decimal(100);
}
const clamp = (d: Decimal) => Decimal.max(0, Decimal.min(100, d));
const buyPctClamped = clamp(buyPct);
const sellPctClamped = clamp(sellPct);
const buyLabel = buyPctClamped
.toDecimalPlaces(0, Decimal.ROUND_DOWN)
.toString();
const sellLabel = sellPctClamped
.toDecimalPlaces(0, Decimal.ROUND_DOWN)
.toString();
return (
<div className={styles.ordersPool__content_stats}>
<div
style={
{
'--width': `${buyPctClamped.toNumber()}%`,
} as React.CSSProperties
}
className={classes(styles.stat_item, styles.buy)}
>
<div className={styles.stat_item__badge}>B</div> {buyLabel}%
</div>
<div
style={
{
'--width': `${sellPctClamped.toNumber()}%`,
} as React.CSSProperties
}
className={classes(styles.stat_item, styles.sell)}
>
{sellLabel}%{' '}
<div className={styles.stat_item__badge}>S</div>
</div>
</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>

View file

@ -12,7 +12,7 @@ export interface OrdersPoolProps {
secondCurrencyName: string;
};
ordersLoading: boolean;
OrdersHistory: PageOrderData[];
ordersHistory: PageOrderData[];
filteredOrdersHistory: PageOrderData[];
trades: Trade[];
tradesLoading: boolean;

View file

@ -10,6 +10,14 @@ interface UseOrderFormParams {
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,
@ -47,7 +55,7 @@ export function useOrderForm({
thisDP: priceDP,
totalDP: priceDP,
setThisState: setPrice,
setTotalState: setTotal,
setTotalState: (v: string) => setTotal(clamp12(v)),
setThisValid: setPriceValid,
setTotalValid,
});
@ -61,7 +69,7 @@ export function useOrderForm({
thisDP: amountDP,
totalDP: priceDP,
setThisState: setAmount,
setTotalState: setTotal,
setTotalState: (v: string) => setTotal(clamp12(v)),
setThisValid: setAmountValid,
setTotalValid,
balance,

View file

@ -159,11 +159,11 @@ function Trading() {
ordersBuySell={ordersBuySell}
ordersLoading={ordersLoading}
filteredOrdersHistory={filteredOrdersHistory}
ordersHistory={ordersHistory}
trades={trades}
tradesLoading={tradesLoading}
setOrdersBuySell={setOrdersBuySell}
takeOrderClick={onHandleTakeOrder}
OrdersHistory={ordersHistory}
/>
</div>

View file

@ -8,7 +8,7 @@
height: 54px;
padding: 0 12px;
> div {
>div {
display: flex;
align-items: center;
gap: 10px;
@ -136,12 +136,12 @@
width: 240px;
height: 42px;
> div:nth-child(1) {
>div:nth-child(1) {
border-radius: 10px;
padding: 11px 20px;
> div {
> div p {
>div {
>div p {
font-size: 14px;
font-weight: 500;
}
@ -160,12 +160,12 @@
}
}
> div:nth-child(2) {
> div {
> div {
>div:nth-child(2) {
>div {
>div {
padding: 11px 20px;
> div {
>div {
p {
font-size: 14px;
font-weight: 500;
@ -233,7 +233,7 @@
gap: 20px;
.input_wrapper {
padding: 13px;
padding-inline: 13px;
max-width: 240px;
max-height: 42px;
display: flex;
@ -244,7 +244,9 @@
border-radius: 10px;
.input {
padding: 0;
border-radius: 0;
padding-inline: 0;
padding-block: 13px;
max-width: 185px;
background-color: transparent;
font-size: 14px;
@ -303,7 +305,7 @@
left: 50%;
transform: translateX(-50%);
> p {
>p {
width: 100%;
font-size: 16px;
text-wrap: wrap;
@ -321,11 +323,11 @@
}
}
> p {
>p {
margin-right: 6px;
}
> input {
>input {
margin-left: 33px;
height: 18px;
width: 18px;
@ -387,7 +389,7 @@
left: 50%;
transform: translateX(-50%);
> p {
>p {
width: 100%;
font-size: 16px;
text-wrap: wrap;
@ -399,14 +401,14 @@
display: block;
}
> svg > * {
>svg>* {
opacity: 1;
}
}
}
}
> .curve__chart {
>.curve__chart {
display: block;
width: 100%;
height: 50px;
@ -417,7 +419,7 @@
align-items: center;
gap: 15px;
> div:first-child {
>div:first-child {
width: 48px;
height: 48px;
padding: 10px;
@ -427,13 +429,13 @@
background: var(--icon-bg-color);
border-radius: 50%;
> img {
>img {
width: auto;
height: 100%;
}
}
> div:last-child {
>div:last-child {
height: 48px;
display: flex;
flex-direction: column;
@ -502,4 +504,4 @@
}
}
}
}
}

View file

@ -3,14 +3,15 @@ import Decimal from 'decimal.js';
import { isPositiveFloatStr } from '@/utils/utils';
import { validateTokensInput } from 'shared/utils';
type SetStr = (_v: string) => void;
interface HandleInputChangeParams {
inputValue: string;
priceOrAmount: 'price' | 'amount';
otherValue: string;
thisDP: number;
totalDP: number;
setThisState: Dispatch<SetStateAction<string>>;
setTotalState: Dispatch<SetStateAction<string>>;
setThisState: SetStr;
setTotalState: SetStr;
setThisValid: Dispatch<SetStateAction<boolean>>;
setTotalValid: Dispatch<SetStateAction<boolean>>;
balance?: string | undefined;
@ -68,18 +69,22 @@ export function handleInputChange({
setThisValid(true);
if (!thisDecimal.isNaN() && !otherDecimal.isNaN() && otherValue !== '') {
const total =
const rawTotal =
priceOrAmount === 'price'
? thisDecimal.mul(otherDecimal)
: otherDecimal.mul(thisDecimal);
setTotalState(total.toString());
const totalClamped = rawTotal.toDecimalPlaces(totalDP, Decimal.ROUND_DOWN);
const totalValid = validateTokensInput(total.toFixed(totalDP), totalDP);
setTotalValid(totalValid.valid);
setTotalState(totalClamped.toString());
const fmtOk = validateTokensInput(totalClamped.toFixed(totalDP), totalDP).valid;
const gtZero = totalClamped.gt(0);
setTotalValid(fmtOk && gtZero);
if (priceOrAmount === 'amount' && balance && setRangeInputValue) {
const percent = thisDecimal.div(balance).mul(100);
const bal = new Decimal(balance || '0');
const percent = bal.gt(0) ? thisDecimal.div(bal).mul(100) : new Decimal(0);
setRangeInputValue(percent.toFixed());
}
} else {