diff --git a/package-lock.json b/package-lock.json index b054b2d..c04fb03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,9 @@ "query-string": "7.1.1", "react": "19.0.0", "react-bootstrap": "2.10.7", + "react-bs-datatable": "^3.15.0", "react-dom": "19.0.0", - "react-markdown": "^10.1.0", + "react-markdown": "10.1.0", "react-router-dom": "7.1.3", "react-scripts": "5.0.1", "stream-browserify": "3.0.0", @@ -21343,6 +21344,23 @@ } } }, + "node_modules/react-bs-datatable": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/react-bs-datatable/-/react-bs-datatable-3.15.0.tgz", + "integrity": "sha512-kcj3r4SbjnlyNAg/muOiTg4TGGtEqGYie5HjLB+V83oDigiMY/Sgv0gyNIE0qgHxxNkICkJOeLesC7EsYCy5lg==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "6.x", + "@fortawesome/free-solid-svg-icons": "6.x", + "@fortawesome/react-fontawesome": "0.x", + "bootstrap": "5.x", + "react": ">=16.8", + "react-bootstrap": "2.x" + } + }, "node_modules/react-dev-utils": { "version": "12.0.1", "license": "MIT", diff --git a/package.json b/package.json index f9c6546..d6c4619 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "query-string": "7.1.1", "react": "19.0.0", "react-bootstrap": "2.10.7", + "react-bs-datatable": "^3.15.0", "react-dom": "19.0.0", "react-markdown": "10.1.0", "react-router-dom": "7.1.3", diff --git a/src/components/app-body/index.js b/src/components/app-body/index.js index bc349b6..8a19a0a 100644 --- a/src/components/app-body/index.js +++ b/src/components/app-body/index.js @@ -27,6 +27,7 @@ import NostrPost from './nostr/nostr-post/index.js' import NostrRead from './nostr/nostr-read/index.js' import GlobalFeed from './nostr/global-feed/index.js' import UserDataReview from './user-data-review' +import Offers from './offers' function AppBody (props) { // Dependency injection through props const appData = props.appData @@ -50,6 +51,7 @@ function AppBody (props) { } /> } /> } /> + } /> {/** Show in all paths except the servers view */} {/* {appData.currentPath !== '/servers' && } */} diff --git a/src/components/app-body/offers/index.js b/src/components/app-body/offers/index.js new file mode 100644 index 0000000..1c8e19b --- /dev/null +++ b/src/components/app-body/offers/index.js @@ -0,0 +1,231 @@ +/* + This React components downloads the active Offers from the REST API and + displays them in a data table. +*/ + +// Global npm libraries +import React, { useState, useEffect, useCallback } from 'react' +import { Container, Row, Col, Table, Button, Spinner } from 'react-bootstrap' +import axios from 'axios' +import { DatatableWrapper, TableBody, TableHeader } from 'react-bs-datatable' + +// Local libraries +import config from '../../../config' +import WaitingModal from '../../waiting-modal' + +// Global variables and constants +const SERVER = `${config.dexServer}/` + +const TABLE_HEADERS = [ + { + prop: 'ticker', + title: 'Ticker', + isFilterable: true + }, + { + prop: 'tokenId', + title: 'Token ID' + }, + { + prop: 'buyOrSell', + title: 'Type' + }, + { + prop: 'smallNostrEventId', + title: 'Event ID' + }, + { + prop: 'numTokens', + title: 'Quantity' + }, + { + prop: 'usdPrice', + title: 'Price (USD)' + }, + { + prop: 'button', + title: 'Action' + } +] + +function Offers (props) { + const [appData] = useState(props.appData) + const [offers, setOffers] = useState([]) + + // Modal state + const [showModal, setShowModal] = useState(false) + const [modalBody, setModalBody] = useState([]) + const [hideSpinner, setHideSpinner] = useState(false) + const [denyClose, setDenyClose] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + // Given a large string, it will return a string with the first and last + // four characters. + const cutString = (str) => { + try { + const subTxid = str.slice(0, 4) + const subTxid2 = str.slice(-4) + return `${subTxid}...${subTxid2}` + } catch (err) { + console.warn('Error in cutString() ', err) + } + } + + // REST request to get data from avax-dex + const getOffers = async () => { + try { + const options = { + method: 'GET', + url: `${SERVER}offer/list/fungible/0`, + data: {} + } + const result = await axios.request(options) + return result.data + } catch (err) { + console.warn('Error in getOffers() ', err) + } + } + + // Get Offer data and manipulate it for the sake of presentation. + const handleOffers = useCallback(async () => { + setIsLoading(true) + // Get raw offer data. + const offerRawData = await getOffers() + console.log('offerRawData: ', offerRawData) + if (!offerRawData || offerRawData.length === 0) { + setIsLoading(false) + setOffers([]) + return + } + // Formatted Data + const formattedOffers = [] + + for (let i = 0; i < offerRawData.length; i++) { + const thisOffer = offerRawData[i] + + // Get and format the token ID + const tokenId = thisOffer.tokenId + const smallTokenId = cutString(tokenId) + thisOffer.tokenId = ({smallTokenId}) + + // Get and format the P2WDB ID + const nostrEventId = thisOffer.nostrEventId + const smallNostrEventId = cutString(nostrEventId) + thisOffer.smallNostrEventId = smallNostrEventId + thisOffer.button = () + + // thisOffer.p2wdbHash = ({smallP2wdbHash}) + + // Convert sats to BCH, and then calculate cost in USD. + const bchjs = appData.wallet.bchjs + const rateInSats = parseInt(thisOffer.rateInBaseUnit) + const bchCost = bchjs.BitcoinCash.toBitcoinCash(rateInSats) + const usdPrice = bchCost * appData.bchWalletState.bchUsdPrice + const priceStr = `$${usdPrice.toFixed(3)}` + thisOffer.usdPrice = priceStr + + formattedOffers.push(thisOffer) + } + + setOffers(formattedOffers) + setIsLoading(false) + }, [appData]) + + const handleBuy = async (event) => { + try { + console.log('Buy button clicked. Event: ', event) + + const targetOfferEventId = event.target.id + console.log('targetOfferEventId: ', targetOfferEventId) + + // Initialize modal + setShowModal(true) + setModalBody(['Generating Counter Offer...', '(This can take a couple minutes)']) + setHideSpinner(false) + setDenyClose(true) + + const options = { + method: 'post', + url: `${SERVER}offer/take`, + data: { + nostrEventId: targetOfferEventId + } + } + + const result = await axios.request(options) + const { eventId, noteId } = result.data + console.log(`Event Id : ${eventId}`) + console.log(`Note Id ${noteId}`) + + // Add link to output + const newModalBody = [] + newModalBody.push('Success!') + // newModalBody.push(P2WDB Entry) + + newModalBody.push('What happens next:') + newModalBody.push('The money has not yet left your wallet! It is still under your control.') + newModalBody.push('If the sellers node is online, they will accept the Counter Offer you just generated in a few minutes.') + newModalBody.push('If the tokens never show up, you can sweep the funds back into your wallet.') + + setModalBody(newModalBody) + setHideSpinner(true) + setDenyClose(false) + } catch (error) { + console.warn('Error in handleBuy() ', error) + setModalBody(['Buy failed: ', error.message]) + setHideSpinner(true) + setDenyClose(false) + } + } + + useEffect(() => { + // Retrieve initial offer data + handleOffers() + + // Get data and update the table periodically. + const interval = setInterval(() => { + handleOffers() + }, 30000) + + // Cleanup interval on component unmount + return () => clearInterval(interval) + }, [handleOffers]) // Empty dependency array means this effect runs once on mount + + const heading = 'Generating Counter Offer...' + + return ( + <> + {showModal && ( + + )} + + + + {!isLoading && ( + + + + +
+
+ )} + +
+ {isLoading && ( + + + + + + )} +
+ + ) +} + +export default Offers diff --git a/src/components/nav-menu/index.js b/src/components/nav-menu/index.js index 573b1e4..63ff4d5 100644 --- a/src/components/nav-menu/index.js +++ b/src/components/nav-menu/index.js @@ -121,6 +121,13 @@ function NavMenu (props) { > Global Feed + + Fungible Tokens +