mirror of
https://github.com/Qortal/q-trade.git
synced 2025-06-20 13:11:21 +00:00
589 lines
19 KiB
TypeScript
589 lines
19 KiB
TypeScript
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { AgGridReact } from 'ag-grid-react';
|
|
import { ColDef, RowClassParams, RowStyle, SizeColumnsToContentStrategy } from 'ag-grid-community';
|
|
import 'ag-grid-community/styles/ag-grid.css';
|
|
import 'ag-grid-community/styles/ag-theme-alpine.css';
|
|
import axios from 'axios';
|
|
import { sendRequestToExtension } from '../../App';
|
|
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Snackbar, SnackbarCloseReason, Typography } from '@mui/material';
|
|
import gameContext from '../../contexts/gameContext';
|
|
import { subscribeToEvent, unsubscribeFromEvent } from '../../utils/events';
|
|
import { useModal } from '../common/useModal';
|
|
import FileSaver from 'file-saver';
|
|
|
|
interface RowData {
|
|
amountQORT: number;
|
|
priceUSD: number;
|
|
totalUSD: number;
|
|
seller: string;
|
|
}
|
|
|
|
export const saveFileToDisk = async (data) => {
|
|
|
|
const dataString = JSON.stringify(data);
|
|
const blob = new Blob([dataString], { type: 'application/json' });
|
|
const fileName = "traderecord_" + Date.now() + '_' + ".json";
|
|
|
|
await FileSaver.saveAs(blob, fileName);
|
|
|
|
}
|
|
|
|
export const autoSizeStrategy: SizeColumnsToContentStrategy = {
|
|
type: 'fitCellContents'
|
|
};
|
|
|
|
export const TradeOffers: React.FC<any> = ({ltcBalance}:any) => {
|
|
const [offers, setOffers] = useState<any[]>([])
|
|
|
|
const { fetchOngoingTransactions, onGoingTrades, updateTransactionInDB, isUsingGateway } = useContext(gameContext);
|
|
const listOfOngoingTradesAts = useMemo(()=> {
|
|
return onGoingTrades?.filter((item)=> item?.status !== 'trade-failed')?.map((trade)=> trade?.qortalAtAddress) || []
|
|
}, [onGoingTrades])
|
|
const {
|
|
isShow: isShowInfo,
|
|
onCancel: onCancelInfo,
|
|
onOk: onOkInfo,
|
|
show: showInfo,
|
|
message: messageInfo,
|
|
} = useModal();
|
|
|
|
const offersWithoutOngoing = useMemo(()=> {
|
|
return offers.filter((item)=> !listOfOngoingTradesAts.includes(item.qortalAtAddress))
|
|
}, [listOfOngoingTradesAts, offers])
|
|
|
|
|
|
|
|
const [selectedOffer, setSelectedOffer] = useState<any>(null)
|
|
const [selectedOffers, setSelectedOffers] = useState<any>([])
|
|
const [record, setRecord] = useState(null)
|
|
const tradePresenceTxns = useRef<any[]>([])
|
|
const offeringTrades = useRef<any[]>([])
|
|
const blockedTradesList = useRef([])
|
|
const gridRef = useRef<any>(null)
|
|
|
|
|
|
const [open, setOpen] = useState(false)
|
|
const [info, setInfo] = useState<any>(null)
|
|
const BuyButton = () => {
|
|
return (
|
|
<button onClick={buyOrder} style={{borderRadius: '8px', width: '74px', height:"30px", background: "#4D7345",
|
|
color: 'white', cursor: 'pointer', border: '1px solid #375232', boxShadow: '0px 2.77px 2.21px 0px #00000005'
|
|
}}>
|
|
BUY
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const defaultColDef = {
|
|
resizable: true, // Make columns resizable by default
|
|
sortable: true, // Make columns sortable by default
|
|
suppressMovable: true, // Prevent columns from being movable
|
|
};
|
|
|
|
const columnDefs: ColDef[] = [
|
|
{
|
|
headerCheckboxSelection: true, // Adds a checkbox in the header for selecting all rows
|
|
checkboxSelection: true, // Adds checkboxes in each row for selection
|
|
headerName: "Select", // You can customize the header name
|
|
width: 50, // Adjust the width as needed
|
|
pinned: 'left', // Optional, to pin this column on the left
|
|
resizable: false,
|
|
},
|
|
{ headerName: "QORT AMOUNT", field: "qortAmount" , flex: 1, // Flex makes this column responsive
|
|
minWidth: 150, // Ensure it doesn't shrink too much
|
|
resizable: true },
|
|
{ headerName: "LTC/QORT", valueGetter: (params) => +params.data.foreignAmount / +params.data.qortAmount, sortable: true, sort: 'asc', flex: 1, // Flex makes this column responsive
|
|
minWidth: 150, // Ensure it doesn't shrink too much
|
|
resizable: true },
|
|
{ headerName: "Total LTC Value", field: "foreignAmount", flex: 1, // Flex makes this column responsive
|
|
minWidth: 150, // Ensure it doesn't shrink too much
|
|
resizable: true },
|
|
{ headerName: "Seller", field: "qortalCreator", flex: 1, // Flex makes this column responsive
|
|
minWidth: 300, // Ensure it doesn't shrink too much
|
|
resizable: true },
|
|
];
|
|
|
|
|
|
|
|
// const onRowClicked = (event: any) => {
|
|
// if(listOfOngoingTradesAts.includes(event.data.qortalAtAddress)) return
|
|
// setSelectedOffer(event.data)
|
|
|
|
// };
|
|
|
|
const restartTradePresenceWebSocket = () => {
|
|
setTimeout(() => initTradePresenceWebSocket(true), 50)
|
|
}
|
|
|
|
|
|
|
|
const getNewBlockedTrades = async () => {
|
|
const unconfirmedTransactionsList = async () => {
|
|
|
|
const unconfirmedTransactionslUrl = `/transactions/unconfirmed?txType=MESSAGE&limit=0&reverse=true`
|
|
|
|
var addBlockedTrades = JSON.parse(localStorage.getItem('failedTrades') || '[]')
|
|
|
|
await fetch(unconfirmedTransactionslUrl).then(response => {
|
|
return response.json()
|
|
}).then(data => {
|
|
data.map((item: any) => {
|
|
const unconfirmedNessageTimeDiff = Date.now() - item.timestamp
|
|
const timeOneHour = 60 * 60 * 1000
|
|
if (Number(unconfirmedNessageTimeDiff) > Number(timeOneHour)) {
|
|
const addBlocked = {
|
|
timestamp: item.timestamp,
|
|
recipient: item.recipient
|
|
}
|
|
addBlockedTrades.push(addBlocked)
|
|
}
|
|
})
|
|
localStorage.setItem("failedTrades", JSON.stringify(addBlockedTrades))
|
|
blockedTradesList.current = JSON.parse(localStorage.getItem('failedTrades') || '[]')
|
|
})
|
|
}
|
|
|
|
await unconfirmedTransactionsList()
|
|
|
|
const filterUnconfirmedTransactionsList = async () => {
|
|
let cleanBlockedTrades = blockedTradesList.current.reduce((newArray, cut: any) => {
|
|
if (cut && !newArray.some((obj: any) => obj.recipient === cut.recipient)) {
|
|
newArray.push(cut)
|
|
}
|
|
return newArray
|
|
}, [] as any[])
|
|
localStorage.setItem("failedTrades", JSON.stringify(cleanBlockedTrades))
|
|
blockedTradesList.current = JSON.parse(localStorage.getItem("failedTrades") || "[]")
|
|
}
|
|
|
|
await filterUnconfirmedTransactionsList()
|
|
processOffersWithPresence()
|
|
}
|
|
|
|
const executeGetNewBlockTrades = useCallback(()=> {
|
|
getNewBlockedTrades()
|
|
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
subscribeToEvent("execute-get-new-block-trades", executeGetNewBlockTrades);
|
|
|
|
return () => {
|
|
unsubscribeFromEvent("execute-get-new-block-trades", executeGetNewBlockTrades);
|
|
};
|
|
}, []);
|
|
|
|
const processOffersWithPresence = () => {
|
|
if (offeringTrades.current === null) return
|
|
async function asyncForEach(array: any, callback: any) {
|
|
for (let index = 0; index < array.length; index++) {
|
|
await callback(array[index], index, array)
|
|
}
|
|
}
|
|
|
|
|
|
const filterOffersUsingTradePresence = (offeringTrade: any) => {
|
|
return offeringTrade.tradePresenceExpiry > Date.now();
|
|
}
|
|
|
|
const startOfferPresenceMapping = async () => {
|
|
if (tradePresenceTxns.current) {
|
|
for (const tradePresence of tradePresenceTxns.current) {
|
|
const offerIndex = offeringTrades.current.findIndex(offeringTrade => offeringTrade.qortalCreatorTradeAddress === tradePresence.tradeAddress);
|
|
if (offerIndex !== -1) {
|
|
offeringTrades.current[offerIndex].tradePresenceExpiry = tradePresence.timestamp;
|
|
}
|
|
}
|
|
}
|
|
|
|
let filteredOffers = offeringTrades.current?.filter((offeringTrade) => filterOffersUsingTradePresence(offeringTrade)) || []
|
|
let tradesPresenceCleaned: any[] = filteredOffers
|
|
|
|
|
|
blockedTradesList.current.forEach((item: any) => {
|
|
const toDelete = item.recipient
|
|
tradesPresenceCleaned = tradesPresenceCleaned?.filter(el => {
|
|
return el.qortalCreatorTradeAddress !== toDelete
|
|
}) || []
|
|
})
|
|
|
|
if (tradesPresenceCleaned) {
|
|
updateGridData(tradesPresenceCleaned)
|
|
}
|
|
}
|
|
|
|
startOfferPresenceMapping()
|
|
}
|
|
|
|
const restartTradeOffersWebSocket = () => {
|
|
setTimeout(() => initTradeOffersWebSocket(true), 50)
|
|
}
|
|
|
|
const initTradePresenceWebSocket = (restarted = false) => {
|
|
let socketTimeout: any
|
|
let socketLink
|
|
if(isUsingGateway){
|
|
socketLink = `wss://appnode.qortal.org/websockets/crosschain/tradepresence`
|
|
} else {
|
|
socketLink = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/websockets/crosschain/tradepresence`;
|
|
|
|
}
|
|
|
|
|
|
const socket = new WebSocket(socketLink)
|
|
socket.onopen = () => {
|
|
setTimeout(pingSocket, 50)
|
|
}
|
|
socket.onmessage = (e) => {
|
|
tradePresenceTxns.current = JSON.parse(e.data)
|
|
processOffersWithPresence()
|
|
restarted = false
|
|
}
|
|
socket.onclose = () => {
|
|
clearTimeout(socketTimeout)
|
|
restartTradePresenceWebSocket()
|
|
}
|
|
socket.onerror = (e) => {
|
|
clearTimeout(socketTimeout)
|
|
}
|
|
const pingSocket = () => {
|
|
socket.send('ping')
|
|
socketTimeout = setTimeout(pingSocket, 295000)
|
|
}
|
|
}
|
|
|
|
const initTradeOffersWebSocket = (restarted = false) => {
|
|
let tradeOffersSocketCounter = 0
|
|
let socketTimeout: any
|
|
|
|
let socketLink
|
|
if(isUsingGateway){
|
|
socketLink = `wss://appnode.qortal.org/websockets/crosschain/tradeoffers?foreignBlockchain=LITECOIN&includeHistoric=true`
|
|
} else {
|
|
socketLink = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/websockets/crosschain/tradeoffers?foreignBlockchain=LITECOIN&includeHistoric=true`
|
|
|
|
}
|
|
const socket = new WebSocket(socketLink)
|
|
socket.onopen = () => {
|
|
setTimeout(pingSocket, 50)
|
|
tradeOffersSocketCounter += 1
|
|
}
|
|
socket.onmessage = (e) => {
|
|
offeringTrades.current = [...offeringTrades.current, ...JSON.parse(e.data)]
|
|
tradeOffersSocketCounter += 1
|
|
restarted = false
|
|
processOffersWithPresence()
|
|
}
|
|
socket.onclose = () => {
|
|
clearTimeout(socketTimeout)
|
|
restartTradeOffersWebSocket()
|
|
}
|
|
socket.onerror = (e) => {
|
|
clearTimeout(socketTimeout)
|
|
}
|
|
const pingSocket = () => {
|
|
socket.send('ping')
|
|
socketTimeout = setTimeout(pingSocket, 295000)
|
|
}
|
|
}
|
|
|
|
|
|
useEffect(() => {
|
|
blockedTradesList.current = JSON.parse(localStorage.getItem('failedTrades') || '[]')
|
|
initTradePresenceWebSocket()
|
|
initTradeOffersWebSocket()
|
|
getNewBlockedTrades()
|
|
const intervalBlockTrades = setInterval(() => {
|
|
getNewBlockedTrades()
|
|
}, 150000)
|
|
|
|
return () => {
|
|
clearInterval(intervalBlockTrades)
|
|
}
|
|
}, [isUsingGateway])
|
|
|
|
|
|
|
|
const selectedTotalLTC = useMemo(() => {
|
|
return selectedOffers.reduce((acc: number, curr: any) => {
|
|
return acc + (+curr.foreignAmount || 0); // Ensure qortAmount is defined
|
|
}, 0);
|
|
}, [selectedOffers]);
|
|
|
|
|
|
const buyOrder = async () => {
|
|
try {
|
|
if(+ltcBalance < +selectedTotalLTC.toFixed(4)){
|
|
setOpen(true)
|
|
setInfo({
|
|
type: 'error',
|
|
message: "You don't have enough LTC or your balance was not retrieved"
|
|
})
|
|
return
|
|
}
|
|
|
|
if (selectedOffers?.length < 1) return
|
|
setOpen(true)
|
|
setInfo({
|
|
type: 'info',
|
|
message: "Attempting to submit buy order. Please wait..."
|
|
})
|
|
const listOfATs = selectedOffers
|
|
|
|
const response = await qortalRequestWithTimeout({
|
|
action: "CREATE_TRADE_BUY_ORDER",
|
|
crosschainAtInfo: listOfATs,
|
|
foreignBlockchain: 'LITECOIN'
|
|
}, 900000);
|
|
|
|
if(response?.error){
|
|
setOpen(true)
|
|
setInfo({
|
|
type: 'error',
|
|
message: response?.error || "Failed to submit trade order."
|
|
})
|
|
return
|
|
}
|
|
if (response?.extra?.atAddresses) {
|
|
setSelectedOffers([])
|
|
const transactionData = {
|
|
qortalAtAddresses: response?.extra?.atAddresses,
|
|
qortAddress: response?.extra?.senderAddress,
|
|
node: response?.extra?.node,
|
|
status:response?.extra?.status ? response?.extra?.status : response.callResponse === true ? 'trade-ongoing' : 'trade-failed',
|
|
encryptedMessageToBase58: response?.encryptedMessageToBase58,
|
|
chatSignature: response?.chatSignature,
|
|
sender: response?.extra?.senderAddress,
|
|
senderPublicKey: response?.extra?.senderPublicKey,
|
|
reference: response?.callResponse?.reference,
|
|
};
|
|
|
|
|
|
|
|
// Update transactions in IndexedDB
|
|
const result = await updateTransactionInDB(transactionData);
|
|
setOpen(true)
|
|
setInfo({
|
|
type: 'success',
|
|
message: "Submitted Order"
|
|
})
|
|
fetchOngoingTransactions()
|
|
if(isUsingGateway){
|
|
setRecord(transactionData)
|
|
await showInfo({
|
|
message: `Keep a record of your order in case your trade gets stuck`,
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
setOpen(true)
|
|
setInfo({
|
|
type: 'error',
|
|
message: error?.message || "Failed to submit trade order."
|
|
})
|
|
console.error(error)
|
|
}
|
|
}
|
|
|
|
|
|
const getRowStyle = (params: RowClassParams<any, any>): RowStyle | undefined => {
|
|
|
|
if (listOfOngoingTradesAts.includes(params.data.qortalAtAddress)) {
|
|
return { background: '#D9D9D91A'};
|
|
}
|
|
if (params.data.qortalAtAddress === selectedOffer?.qortalAtAddress) {
|
|
return { background: '#6D94F533'};
|
|
}
|
|
return undefined;
|
|
};
|
|
// const onGridReady = (params) => {
|
|
// const allColumnIds = params.columnApi.getAllColumns().map(col => col.getColId());
|
|
// params.columnApi.autoSizeColumns(allColumnIds, false);
|
|
// };
|
|
|
|
const onSelectionChanged = (event: any) => {
|
|
const selectedRows = event.api.getSelectedRows();
|
|
|
|
setSelectedOffers([...selectedRows]); // Set all selected rows
|
|
};
|
|
|
|
const onRowClicked = (event: any) => {
|
|
if (listOfOngoingTradesAts.includes(event.data.qortalAtAddress)) return;
|
|
const selectedRows = gridRef.current?.api.getSelectedRows();
|
|
setSelectedOffers([...selectedRows]); // Always spread the array to ensure state updates correctly
|
|
};
|
|
|
|
|
|
const updateGridData = (newData: any) => {
|
|
if (gridRef.current) {
|
|
|
|
setOffers(newData);
|
|
|
|
}
|
|
};
|
|
|
|
const getRowId = useCallback(function (params: any) {
|
|
return String(params.data.qortalAtAddress);
|
|
}, []);
|
|
|
|
const selectedTotalQORT = useMemo(() => {
|
|
return selectedOffers.reduce((acc: number, curr: any) => {
|
|
return acc + (+curr.qortAmount || 0); // Ensure qortAmount is defined
|
|
}, 0);
|
|
}, [selectedOffers]);
|
|
|
|
|
|
const onGridReady = useCallback((params: any) => {
|
|
params.api.sizeColumnsToFit(); // Adjust columns to fit the grid width
|
|
const allColumnIds = params.columnApi.getAllColumns().map((col: any) => col.getColId());
|
|
params.columnApi.autoSizeColumns(allColumnIds); // Automatically adjust the width to fit content
|
|
}, []);
|
|
|
|
|
|
const handleClose = (
|
|
event?: React.SyntheticEvent | Event,
|
|
reason?: SnackbarCloseReason,
|
|
) => {
|
|
if (reason === 'clickaway') {
|
|
return;
|
|
}
|
|
|
|
setOpen(false);
|
|
setInfo(null)
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
<Box sx={{
|
|
width: '100%',
|
|
}}>
|
|
<div className="ag-theme-alpine-dark" style={{ height: 400, width: '100%' }}>
|
|
<AgGridReact
|
|
ref={gridRef}
|
|
columnDefs={columnDefs}
|
|
defaultColDef={defaultColDef}
|
|
rowData={offersWithoutOngoing}
|
|
onRowClicked={onRowClicked}
|
|
onSelectionChanged={onSelectionChanged}
|
|
getRowStyle={getRowStyle}
|
|
autoSizeStrategy={autoSizeStrategy}
|
|
rowSelection="multiple" // Enable multi-select
|
|
rowMultiSelectWithClick={true}
|
|
suppressHorizontalScroll={false} // Allow horizontal scroll on mobile if needed
|
|
suppressCellFocus={true} // Prevents cells from stealing focus in mobile
|
|
// pagination={true}
|
|
// paginationPageSize={10}
|
|
onGridReady={onGridReady}
|
|
// domLayout='autoHeight'
|
|
getRowId={(params) => params.data.qortalAtAddress} // Ensure rows have unique IDs
|
|
/>
|
|
{/* {selectedOffer && (
|
|
<Button onClick={buyOrder}>Buy</Button>
|
|
|
|
)} */}
|
|
</div>
|
|
<Box sx={{
|
|
width: '100%',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
position: 'fixed',
|
|
bottom: '0px',
|
|
height: '100px',
|
|
padding: '7px',
|
|
background: '#181d1f',
|
|
|
|
}}>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
gap: '5px',
|
|
flexDirection: 'column',
|
|
width: '100%'
|
|
}}>
|
|
<Typography sx={{
|
|
fontSize: '16px',
|
|
color: 'white',
|
|
width: 'calc(100% - 75px)'
|
|
}}>{selectedTotalQORT?.toFixed(3)} QORT</Typography>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
gap: '20px',
|
|
alignItems: 'center',
|
|
width: 'calc(100% - 75px)'
|
|
}}>
|
|
<Typography sx={{
|
|
fontSize: '16px',
|
|
color: selectedTotalLTC > ltcBalance ? 'red' : 'white',
|
|
}}><span>{selectedTotalLTC?.toFixed(4)}</span> <span style={{
|
|
marginLeft: 'auto'
|
|
}}>LTC</span></Typography>
|
|
|
|
|
|
</Box>
|
|
<Typography sx={{
|
|
fontSize: '16px',
|
|
color: 'white',
|
|
|
|
}}><span>{ltcBalance?.toFixed(4)}</span> <span style={{
|
|
marginLeft: 'auto'
|
|
}}>LTC balance</span></Typography>
|
|
</Box>
|
|
{BuyButton()}
|
|
</Box>
|
|
<Snackbar anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} open={open} onClose={handleClose}>
|
|
<Alert
|
|
|
|
|
|
onClose={handleClose}
|
|
severity={info?.type}
|
|
variant="filled"
|
|
sx={{ width: '100%' }}
|
|
>
|
|
{info?.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
{isShowInfo && (
|
|
<Dialog
|
|
open={isShowInfo}
|
|
aria-labelledby="alert-dialog-title"
|
|
aria-describedby="alert-dialog-description"
|
|
>
|
|
<DialogTitle id="alert-dialog-title">{"Download record"}</DialogTitle>
|
|
<DialogContent>
|
|
<DialogContentText id="alert-dialog-description" sx={{
|
|
color: 'white'
|
|
}}>
|
|
{messageInfo.message}
|
|
</DialogContentText>
|
|
<Button onClick={async ()=> {
|
|
try {
|
|
const fileName = "traderecord_" + Date.now() + '_' + ".json";
|
|
const dataString = JSON.stringify(record);
|
|
const blob = new Blob([dataString], { type: 'application/json' });
|
|
await qortalRequest({
|
|
action: 'SAVE_FILE',
|
|
filename: fileName,
|
|
blob
|
|
})
|
|
} catch (error) {
|
|
|
|
}
|
|
}}>Save Record</Button>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
|
|
<Button variant="contained" onClick={onOkInfo} autoFocus>
|
|
Close
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|