Compare commits

...
8 Commits
Author SHA1 Message Date
Chris Troutner aa2d92bfaf Merge pull request #79 from Permissionless-Software-Foundation/dh-counter-offer-data
feat(offer): Added more Counter Offer data
2025-11-20 05:28:08 -08:00
Daniel Gonzalez 40467014f0 feat(offer): Added more Counter Offer data 2025-11-19 21:30:41 -04:00
Chris Troutner d94b3fc853 Merge pull request #78 from Permissionless-Software-Foundation/dh-counter-offers-page
feat():Counter Offers Page: Set up test environment
2025-11-17 17:24:31 -08:00
Daniel Gonzalez 51ed5601fa feat():Counter Offers Page: Set up test environment 2025-11-17 18:00:52 -04:00
Chris Troutner d72d98a2d7 Merge pull request #77 from Permissionless-Software-Foundation/ct-unstable
fix(nft card): Seller link only works if npub is defined
2025-11-14 11:15:37 -08:00
Chris Troutner 7237cb6c6a fix(nft card): Seller link only works if npub is defined 2025-11-14 11:14:55 -08:00
Chris Troutner 93c8eb42d3 Merge pull request #76 from Permissionless-Software-Foundation/dh-seller-link
feat(sell): Added link to Seller profile
2025-11-14 08:43:01 -08:00
Daniel Gonzalez fa3c71f0a1 feat(sell): Added link to Seller profile 2025-11-13 17:07:41 -04:00
11 changed files with 446 additions and 14 deletions
+4 -4
View File
@@ -15,7 +15,7 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.2.0",
"bch-dex-lib": "2.3.1",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
@@ -10043,9 +10043,9 @@
"license": "MIT"
},
"node_modules/bch-dex-lib": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.2.0.tgz",
"integrity": "sha512-SORqn4qNmbxfL2SbbapOpbcCB+FtqasR+GT0XRifdkXFjA3aDL0gwMsHjh8IzLZnKtPYSBcfgzKQFxEsXCJNug==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/bch-dex-lib/-/bch-dex-lib-2.3.1.tgz",
"integrity": "sha512-HlY21BpUUaSsuZNWF5LGPwSJU7m2kYbNMcS36o22RSHuO1kiBMONHwkV+v22kdbDk3T/Iz+AZN0mzJvH5neCXw==",
"license": "MIT",
"dependencies": {
"@chris.troutner/retry-queue": "1.0.10",
+1 -1
View File
@@ -9,7 +9,7 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@noble/hashes": "1.8.0",
"axios": "0.27.2",
"bch-dex-lib": "2.2.0",
"bch-dex-lib": "2.3.1",
"bch-message-lib": "2.2.1",
"bch-nostr": "1.3.4",
"bch-token-sweep": "2.2.1",
@@ -0,0 +1,71 @@
/*
This Card component displays a counter offer with token icon, name, and price.
*/
// Global npm libraries
import React, { useState } from 'react'
import { Container, Row, Col, Card, Button } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
function CounterOfferCard (props) {
const { offer } = props
const [icon, setIcon] = useState(offer.tokenIcon)
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card className='shadow-sm'>
<Card.Body style={{ textAlign: 'center', padding: '10px' }}>
{/** If the icon is loaded, display it */}
{icon && (
<Card.Img
src={icon}
style={{ height: '100px', width: 'auto', margin: '0 auto' }}
onError={(e) => {
setIcon(null) // Set the icon to null if it fails to load the image url.
}}
/>
)}
{/** If the icon is not loaded, display the Jdenticon */}
{!icon && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '10px' }}>
<Jdenticon size='100' value={offer.tokenId} />
</div>
)}
<Card.Title style={{ textAlign: 'center', marginTop: '10px' }}>
<h4>{offer.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
{/* <strong>{offer.tokenName}</strong> */}
<strong>Counter Offer UTXO</strong>
</Col>
</Row>
<br />
<Row>
{/* <Col>Price:</Col> */}
<Col><strong>{offer.price}</strong></Col>
</Row>
<br />
<Row className='text-center'>
<Col>
<Button disabled variant='danger' size='sm'>
Cancel
</Button>
</Col>
</Row>
</Container>
</Card.Body>
</Card>
</Col>
</>
)
}
export default CounterOfferCard
@@ -0,0 +1,90 @@
/*
Shows Counter Offers created by the user.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Spinner } from 'react-bootstrap'
// Local libraries
import CounterOfferCard from './counter-offer-card'
import AsyncLoad from '../../../services/async-load'
function CounterOffers (props) {
const appData = props.appData
const [counterOffers, setCounterOffers] = useState([])
const [isLoading, setIsLoading] = useState(true)
// Generate counter offer cards
const generateCards = () => {
return counterOffers.map((offer) => (
<CounterOfferCard
key={offer.id}
offer={offer}
appData={appData}
/>
))
}
useEffect(() => {
const loadWallet = async () => {
try {
setIsLoading(true)
const { bchWalletState, serverUrl } = appData
const asyncLoad = new AsyncLoad()
await asyncLoad.loadWalletLib()
const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1")
const utxoStore = counterOfferWallet.utxos.utxoStore
const bchUtxos = utxoStore.bchUtxos
setCounterOffers(bchUtxos)
console.log('counterOffers', bchUtxos)
setIsLoading(false)
} catch (error) {
setIsLoading(false)
console.log('error', error)
}
}
loadWallet()
}, [appData])
return (
<Container>
<Row>
<Col>
<h1>Counter Offers</h1>
<p className='text-muted'>Your pending counter offers</p>
</Col>
</Row>
{isLoading
? (
<Row className='text-center' style={{ padding: '50px' }}>
<Col>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
<p className='mt-3 text-muted'>Loading counter offers...</p>
</Col>
</Row>
)
: (
<>
<Row>
{generateCards()}
</Row>
{counterOffers.length === 0 && (
<Row className='text-center'>
<Col>
<p>No counter offers found.</p>
</Col>
</Row>
)}
</>
)}
</Container>
)
}
export default CounterOffers
+2
View File
@@ -30,6 +30,7 @@ import ContentCreators from './nostr/content-creators/index.js'
import UserDataReview from './user-data-review'
import Offers from './offers'
import NostrChat from './nostr-chat'
import CounterOffers from './counter-offers'
function AppBody (props) {
// Dependency injection through props
const appData = props.appData
@@ -55,6 +56,7 @@ function AppBody (props) {
<Route path='/content-creators' element={<ContentCreators appData={appData} />} />
<Route path='/user-data/:tokenId' element={<UserDataReview appData={appData} />} />
<Route path='/offers' element={<Offers appData={appData} />} />
<Route path='/counter-offers' element={<CounterOffers appData={appData} />} />
<Route path='/nostr-chat' element={<NostrChat appData={appData} />} />
</Routes>
{/** Show in all paths except the servers view */}
@@ -6,6 +6,7 @@
// Global npm libraries
import React, { useState } from 'react'
import { Button, Modal, Container, Row, Col, Spinner } from 'react-bootstrap'
import AsyncLoad from '../../../services/async-load'
function BuyButton (props) {
const { token, appData, onSuccess } = props
@@ -45,10 +46,19 @@ function BuyButton (props) {
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
const { offerData, partialHex, counterOfferUtxo } = await bchDexLib.take.takeOffer(
targetOffer
)
// Get counter offer data
const asyncLoad = new AsyncLoad()
const { takerAddr, takerNpub, counterOfferAddr } = await asyncLoad.getCounterOfferMetadata(appData)
offerData.takerAddr = takerAddr
offerData.takerNpub = takerNpub
offerData.counterOfferAddr = counterOfferAddr
offerData.counterOfferUtxo = counterOfferUtxo.txid
progress.push(<p key='progress-msg2'>Uploading counter offer to Nostr...</p>)
setProgressMsg(progress)
@@ -0,0 +1,186 @@
/*
This component displays the seller's profile information for an NFT listing.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUser } from '@fortawesome/free-solid-svg-icons'
function SellerProfile (props) {
const { npub, appData } = props
const [profile, setProfile] = useState(null)
const [imageError, setImageError] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const { nostrQueries } = appData
// Get profile if npub is provided.
useEffect(() => {
const start = async () => {
setIsLoading(true)
try {
const pubKey = nostrQueries.npubToHex(npub)
const profile = await nostrQueries.getProfile(pubKey)
setProfile(profile)
} catch (error) {
console.warn('Error fetching seller profile:', error)
} finally {
setIsLoading(false)
}
}
if (npub) {
start()
} else {
setIsLoading(false)
}
}, [npub, nostrQueries])
// handle img url errors
const handleImageError = (type) => {
setImageError(true)
}
// go to profile
const goToProfile = () => {
if (npub) {
const profileUrl = `${window.location.origin}/profile/${npub}#single-view`
window.open(profileUrl, '_blank')
}
}
// Get display name - use profile name, npub short form, or "Anonymous User"
const getDisplayName = () => {
if (profile?.name) return profile.name
if (npub) {
return npub.slice(0, 8) + '...' + npub.slice(-6)
}
return 'Anonymous User'
}
return (
<div
onClick={goToProfile}
className='seller-profile-container'
style={{
padding: '4px 8px',
backgroundColor: '#f8f9fa',
borderRadius: '6px',
border: '1px solid #e9ecef',
transition: 'all 0.2s ease',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
width: '100%'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#e9ecef'
e.currentTarget.style.borderColor = '#dee2e6'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#f8f9fa'
e.currentTarget.style.borderColor = '#e9ecef'
}}
>
<div className='d-flex align-items-center justify-content-center gap-1'>
{/* Seller Label */}
<small
className='text-muted fw-semibold'
style={{
fontSize: '0.65rem',
textTransform: 'uppercase',
letterSpacing: '0.3px'
}}
>
Seller:
</small>
{/* Profile Picture or Placeholder */}
<div
className='d-flex align-items-center justify-content-center'
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
backgroundColor: '#e9ecef',
border: '1.5px solid #dee2e6',
overflow: 'hidden',
flexShrink: 0,
cursor: 'pointer',
transition: 'transform 0.2s ease'
}}
onClick={(e) => {
e.stopPropagation()
goToProfile()
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
}}
>
{isLoading
? (
<FontAwesomeIcon
icon={faUser}
style={{
color: '#adb5bd',
fontSize: '12px',
opacity: 0.5
}}
/>
)
: (profile?.picture && !imageError)
? (
<img
src={profile.picture}
alt='Seller profile'
className='w-100 h-100'
style={{ objectFit: 'cover' }}
onError={() => handleImageError('picture')}
/>
)
: (
<FontAwesomeIcon
icon={faUser}
style={{
color: '#6c757d',
fontSize: '12px'
}}
/>
)}
</div>
{/* Seller Name */}
<span
onClick={(e) => {
e.stopPropagation()
goToProfile()
}}
className='cursor-pointer fw-medium'
style={{
color: '#495057',
fontSize: '0.8rem',
transition: 'color 0.2s ease',
maxWidth: '120px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#007bff'
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#495057'
}}
title={profile?.name || npub}
>
{getDisplayName()}
</span>
</div>
</div>
)
}
export default SellerProfile
@@ -10,14 +10,12 @@ import Jdenticon from '@chris.troutner/react-jdenticon'
// Local libraries
import InfoButton from './info-button'
import BuyButton from './buy-button'
import SellerProfile from './seller-profile'
function TokenCard (props) {
const { token, appData, handleRefresh, hideBuyBtn } = props
const [icon, setIcon] = useState(token.icon)
const [tokenData, setTokenData] = useState(token.tokenData)
console.log('TokenCard() appData: ', appData)
// Update icon state every token.icon and token.tokenData changes
useEffect(() => {
console.log('setting icon')
@@ -28,8 +26,14 @@ function TokenCard (props) {
return (
<>
<Col xs={12} sm={6} lg={4} style={{ padding: '25px' }}>
<Card>
<Card.Body style={{ textAlign: 'center' }}>
<Card className='shadow-sm'>
<Card.Body style={{ textAlign: 'center', padding: '10px' }}>
{/* Seller Profile Section */}
<Row className='mb-2'>
<Col className='text-center mb-2'>
<SellerProfile npub={token.makerNpub} appData={appData} />
</Col>
</Row>
{/** If the icon is loaded, display it */
icon && (
<Card.Img
@@ -50,7 +54,6 @@ function TokenCard (props) {
<Card.Title style={{ textAlign: 'center', marginTop: '10px' }}>
<h4>{token.ticker}</h4>
</Card.Title>
<Container>
<Row>
<Col>
+11 -2
View File
@@ -8,7 +8,7 @@ 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'
import AsyncLoad from '../../../services/async-load'
// Local libraries
import config from '../../../config'
import WaitingModal from '../../waiting-modal'
@@ -100,10 +100,19 @@ function Offers (props) {
// Generate a counter offer.
const bchDexLib = appData.dexLib
const { offerData, partialHex } = await bchDexLib.take.takeOffer(
const { offerData, partialHex, counterOfferUtxo } = await bchDexLib.take.takeOffer(
targetOfferEventId
)
// Get counter offer data
const asyncLoad = new AsyncLoad()
const { takerAddr, takerNpub, counterOfferAddr } = await asyncLoad.getCounterOfferMetadata(appData)
offerData.takerAddr = takerAddr
offerData.takerNpub = takerNpub
offerData.counterOfferAddr = counterOfferAddr
offerData.counterOfferUtxo = counterOfferUtxo.txid
// Upload the counter offer to Nostr.
const nostr = appData.nostr
const { eventId, noteId } = await nostr.testNostrUpload({
+8
View File
@@ -58,6 +58,14 @@ function NavMenu (props) {
Fungible Tokens
</NavLink>
<NavLink
className={currentPath === '/counter-offers' ? 'nav-link-active' : 'nav-link-inactive'}
to='/counter-offers'
onClick={handleClickEvent}
>
Counter Offers
</NavLink>
<hr />
<NavLink
+53
View File
@@ -333,6 +333,59 @@ class AsyncLoad {
throw error
}
}
async getDerivatedWallet (restURL, mnemonic, hdPath = "m/44'/245'/0'/0/0", initialize = true) {
try {
const options = {
interface: 'consumer-api',
restURL,
noUpdate: true,
hdPath
}
const wallet = new this.BchWallet(mnemonic, options)
// Wait for wallet to initialize.
await wallet.walletInfoPromise
if (initialize) {
await wallet.initialize()
}
return wallet
} catch (error) {
console.error('Error initStarterWallet: ', error)
throw error
}
}
// Get buyer and counter offer data.
async getCounterOfferMetadata (appData) {
try {
const { bchWalletState, serverUrl } = appData
// Start async load lib
const asyncLoad = new AsyncLoad()
await asyncLoad.loadWalletLib()
// Buyer wallet data
const buyerWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/0", false)
const buyerAddr = buyerWallet.walletInfo.cashAddress
const buyerKeyPair = asyncLoad.nostrKeyPairFromWIF(buyerWallet.walletInfo.privateKey)
const buyerNpub = buyerKeyPair.npub
// Counter offer wallet data
const counterOfferWallet = await asyncLoad.getDerivatedWallet(serverUrl, bchWalletState.mnemonic, "m/44'/245'/0'/0/1", false)
const counterOfferAddr = counterOfferWallet.walletInfo.cashAddress
return {
takerAddr: buyerAddr,
takerNpub: buyerNpub,
counterOfferAddr
}
} catch (error) {
console.error('Error getCounterOfferMetadata()', error)
throw error
}
}
}
function sleep (ms) {