import React, { Component } from 'react'; import { withTranslation } from 'react-i18next'; import { TextField, Chip, Tooltip, IconButton, Badge, Tab, Tabs, Alert, Paper, CircularProgress, Button, Grid, Typography, List, ListItem, ListItemIcon, ListItemText, ListItemAvatar, Avatar, Divider, Box, LinearProgress, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, } from '@mui/material'; import Countdown, { zeroPad } from 'react-countdown'; import { StoreTokenDialog, NoRobotDialog } from './Dialogs'; import currencyDict from '../../static/assets/currencies.json'; import PaymentText from './PaymentText'; import TradeBox from './TradeBox'; import FlagWithProps from './FlagWithProps'; import LinearDeterminate from './LinearDeterminate'; import MediaQuery from 'react-responsive'; import { t } from 'i18next'; // icons import AccessTimeIcon from '@mui/icons-material/AccessTime'; import NumbersIcon from '@mui/icons-material/Numbers'; import PriceChangeIcon from '@mui/icons-material/PriceChange'; import PaymentsIcon from '@mui/icons-material/Payments'; import ArticleIcon from '@mui/icons-material/Article'; import HourglassTopIcon from '@mui/icons-material/HourglassTop'; import CheckIcon from '@mui/icons-material/Check'; import { SendReceiveIcon } from './Icons'; import { getCookie } from '../utils/cookies'; import { pn } from '../utils/prettyNumbers'; import { copyToClipboard } from '../utils/clipboard'; import { getWebln } from '../utils/webln'; class OrderPage extends Component { constructor(props) { super(props); this.state = { is_explicit: false, delay: 60000, // Refresh every 60 seconds by default total_secs_exp: 300, loading: true, openCancel: false, openCollaborativeCancel: false, openInactiveMaker: false, openWeblnDialog: false, waitingWebln: false, openStoreToken: false, tabValue: 1, orderId: this.props.match.params.orderId, }; // Refresh delays according to Order status this.statusToDelay = { 0: 2000, // 'Waiting for maker bond' 1: 25000, // 'Public' 2: 90000, // 'Paused' 3: 2000, // 'Waiting for taker bond' 4: 999999, // 'Cancelled' 5: 999999, // 'Expired' 6: 6000, // 'Waiting for trade collateral and buyer invoice' 7: 8000, // 'Waiting only for seller trade collateral' 8: 8000, // 'Waiting only for buyer invoice' 9: 10000, // 'Sending fiat - In chatroom' 10: 10000, // 'Fiat sent - In chatroom' 11: 30000, // 'In dispute' 12: 999999, // 'Collaboratively cancelled' 13: 3000, // 'Sending satoshis to buyer' 14: 999999, // 'Sucessful trade' 15: 10000, // 'Failed lightning network routing' 16: 180000, // 'Wait for dispute resolution' 17: 180000, // 'Maker lost dispute' 18: 180000, // 'Taker lost dispute' }; } completeSetState = (newStateVars) => { // In case the reply only has "bad_request" // Do not substitute these two for "undefined" as // otherStateVars will fail to assign values if (newStateVars.currency == null) { newStateVars.currency = this.state.currency; newStateVars.amount = this.state.amount; newStateVars.status = this.state.status; } const otherStateVars = { amount: newStateVars.amount ? newStateVars.amount : null, loading: false, delay: this.setDelay(newStateVars.status), currencyCode: this.getCurrencyCode(newStateVars.currency), penalty: newStateVars.penalty, // in case penalty time has finished, it goes back to null invoice_expired: newStateVars.invoice_expired, // in case invoice had expired, it goes back to null when it is valid again }; const completeStateVars = Object.assign({}, newStateVars, otherStateVars); this.setState(completeStateVars); }; getOrderDetails = (id) => { this.setState({ orderId: id }); fetch('/api/order' + '?order_id=' + id) .then((response) => response.json()) .then(this.orderDetailsReceived); }; orderDetailsReceived = (data) => { if (data.status !== this.state.status) { this.handleWebln(data); } this.completeSetState(data); this.setState({ pauseLoading: false }); }; // These are used to refresh the data componentDidMount() { this.getOrderDetails(this.props.match.params.orderId); this.interval = setInterval(this.tick, this.state.delay); } componentDidUpdate() { clearInterval(this.interval); this.interval = setInterval(this.tick, this.state.delay); } componentWillUnmount() { clearInterval(this.interval); } tick = () => { this.getOrderDetails(this.state.orderId); }; handleWebln = async (data) => { const webln = await getWebln(); // If Webln implements locked payments compatibility, this logic might be simplier if (data.is_maker & (data.status == 0)) { webln.sendPayment(data.bond_invoice); this.setState({ waitingWebln: true, openWeblnDialog: true }); } else if (data.is_taker & (data.status == 3)) { webln.sendPayment(data.bond_invoice); this.setState({ waitingWebln: true, openWeblnDialog: true }); } else if (data.is_seller & (data.status == 6 || data.status == 7)) { webln.sendPayment(data.escrow_invoice); this.setState({ waitingWebln: true, openWeblnDialog: true }); } else if (data.is_buyer & (data.status == 6 || data.status == 8)) { this.setState({ waitingWebln: true, openWeblnDialog: true }); webln .makeInvoice(data.trade_satoshis) .then((invoice) => { if (invoice) { this.sendWeblnInvoice(invoice.paymentRequest); this.setState({ waitingWebln: false, openWeblnDialog: false }); } }) .catch(() => { this.setState({ waitingWebln: false, openWeblnDialog: false }); }); } else { this.setState({ waitingWebln: false }); } }; sendWeblnInvoice = (invoice) => { const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') }, body: JSON.stringify({ action: 'update_invoice', invoice, }), }; fetch('/api/order/' + '?order_id=' + this.state.orderId, requestOptions) .then((response) => response.json()) .then((data) => this.completeSetState(data)); }; // Countdown Renderer callback with condition countdownRenderer = ({ total, hours, minutes, seconds, completed }) => { const { t } = this.props; if (completed) { // Render a completed state return {t('The order has expired')}; } else { let col = 'inherit'; const fraction_left = total / 1000 / this.state.total_secs_exp; // Make orange at 25% of time left if (fraction_left < 0.25) { col = 'orange'; } // Make red at 10% of time left if (fraction_left < 0.1) { col = 'red'; } // Render a countdown, bold when less than 25% return fraction_left < 0.25 ? ( {hours}h {zeroPad(minutes)}m {zeroPad(seconds)}s{' '} ) : ( {hours}h {zeroPad(minutes)}m {zeroPad(seconds)}s{' '} ); } }; timerRenderer(seconds) { const hours = parseInt(seconds / 3600); const minutes = parseInt((seconds - hours * 3600) / 60); return ( {hours > 0 ? hours + 'h' : ''} {minutes > 0 ? zeroPad(minutes) + 'm' : ''}{' '} ); } // Countdown Renderer callback with condition countdownPenaltyRenderer = ({ minutes, seconds, completed }) => { const { t } = this.props; if (completed) { // Render a completed state return {t('Penalty lifted, good to go!')}; } else { return ( {' '} {t('You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s', { timeMin: zeroPad(minutes), timeSec: zeroPad(seconds), })}{' '} ); } }; handleTakeAmountChange = (e) => { if ((e.target.value != '') & (e.target.value != null)) { this.setState({ takeAmount: parseFloat(e.target.value) }); } else { this.setState({ takeAmount: e.target.value }); } }; amountHelperText = () => { const { t } = this.props; if ((this.state.takeAmount < this.state.min_amount) & (this.state.takeAmount != '')) { return t('Too low'); } else if ((this.state.takeAmount > this.state.max_amount) & (this.state.takeAmount != '')) { return t('Too high'); } else { return null; } }; takeOrderButton = () => { const { t } = this.props; if (this.state.has_range) { return ( {this.InactiveMakerDialog()} {this.tokenDialog()}
this.state.max_amount) & (this.state.takeAmount != '') } helperText={this.amountHelperText()} label={t('Amount {{currencyCode}}', { currencyCode: this.state.currencyCode })} size='small' type='number' required={true} value={this.state.takeAmount} inputProps={{ min: this.state.min_amount, max: this.state.max_amount, style: { textAlign: 'center' }, }} onChange={this.handleTakeAmountChange} />
this.state.max_amount || this.state.takeAmount == '' || this.state.takeAmount == null ? '' : 'none', }} >
this.state.max_amount || this.state.takeAmount == '' || this.state.takeAmount == null ? 'none' : '', }} >
); } else { return ( <> {this.InactiveMakerDialog()} {this.tokenDialog()} ); } }; countdownTakeOrderRenderer = ({ seconds, completed }) => { if (isNaN(seconds)) { return this.takeOrderButton(); } if (completed) { // Render a completed state return this.takeOrderButton(); } else { return (
); } }; takeOrder = () => { this.setState({ loading: true }); const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') }, body: JSON.stringify({ action: 'take', amount: this.state.takeAmount, }), }; fetch('/api/order/' + '?order_id=' + this.state.orderId, requestOptions) .then((response) => response.json()) .then((data) => this.handleWebln(data) & this.completeSetState(data)); }; // set delay to the one matching the order status. If null order status, delay goes to 9999999. setDelay = (status) => { return status >= 0 ? this.statusToDelay[status.toString()] : 99999999; }; getCurrencyCode(val) { const code = val ? currencyDict[val.toString()] : ''; return code; } handleClickConfirmCancelButton = () => { this.setState({ loading: true }); const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') }, body: JSON.stringify({ action: 'cancel', }), }; fetch('/api/order/' + '?order_id=' + this.state.orderId, requestOptions) .then((response) => response.json()) .then(() => this.getOrderDetails(this.state.orderId) & this.setState({ status: 4 })); this.handleClickCloseConfirmCancelDialog(); }; handleClickOpenConfirmCancelDialog = () => { this.setState({ openCancel: true }); }; handleClickCloseConfirmCancelDialog = () => { this.setState({ openCancel: false }); }; CancelDialog = () => { const { t } = this.props; return ( {t('Cancel the order?')} {t('If the order is cancelled now you will lose your bond.')} ); }; handleClickOpenInactiveMakerDialog = () => { this.setState({ openInactiveMaker: true }); }; handleClickCloseInactiveMakerDialog = () => { this.setState({ openInactiveMaker: false }); }; InactiveMakerDialog = () => { const { t } = this.props; return ( {t('The maker is away')} {t( 'By taking this order you risk wasting your time. If the maker does not proceed in time, you will be compensated in satoshis for 50% of the maker bond.', )} ); }; tokenDialog = () => { return getCookie('robot_token') ? ( this.setState({ openStoreToken: false })} onClickCopy={() => copyToClipboard(getCookie('robot_token')) & this.props.setAppState({ copiedToken: true }) } copyIconColor={this.props.copiedToken ? 'inherit' : 'primary'} onClickBack={() => this.setState({ openStoreToken: false })} onClickDone={() => this.setState({ openStoreToken: false }) & (this.state.maker_status == 'Inactive' ? this.handleClickOpenInactiveMakerDialog() : this.takeOrder()) } /> ) : ( this.setState({ openStoreToken: false })} /> ); }; handleClickConfirmCollaborativeCancelButton = () => { const requestOptions = { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCookie('csrftoken') }, body: JSON.stringify({ action: 'cancel', }), }; fetch('/api/order/' + '?order_id=' + this.state.orderId, requestOptions) .then((response) => response.json()) .then(() => this.getOrderDetails(this.state.orderId) & this.setState({ status: 4 })); this.handleClickCloseCollaborativeCancelDialog(); }; handleClickOpenCollaborativeCancelDialog = () => { this.setState({ openCollaborativeCancel: true }); }; handleClickCloseCollaborativeCancelDialog = () => { this.setState({ openCollaborativeCancel: false }); }; CollaborativeCancelDialog = () => { const { t } = this.props; return ( {t('Collaborative cancel the order?')} {t( 'The trade escrow has been posted. The order can be cancelled only if both, maker and taker, agree to cancel.', )} ); }; BackButton = () => { const { t } = this.props; // If order has expired, show back button. if (this.state.status == 5) { return ( ); } return null; }; CancelButton = () => { const { t } = this.props; // If maker and Waiting for Bond. Or if taker and Waiting for bond. // Simply allow to cancel without showing the cancel dialog. if ( this.state.is_maker & [0, 1, 2].includes(this.state.status) || this.state.is_taker & (this.state.status == 3) ) { return ( ); } // If the order does not yet have an escrow deposited. Show dialog // to confirm forfeiting the bond if ([3, 6, 7].includes(this.state.status)) { return (
{this.CancelDialog()}
); } // If the escrow is Locked, show the collaborative cancel button. if ([8, 9].includes(this.state.status)) { return ( {this.CollaborativeCancelDialog()} ); } // If none of the above do not return a cancel button. return null; }; // Colors for the status badges statusBadgeColor(status) { if (status == 'Active') { return 'success'; } if (status == 'Seen recently') { return 'warning'; } if (status == 'Inactive') { return 'error'; } } orderBox = () => { const { t } = this.props; return ( {t('Order Box')} {' '} {!this.state.type ? ( ) : ( )} } > {this.state.is_participant ? ( <> {this.state.taker_nick != 'None' ? ( <> {' '} {this.state.type ? ( ) : ( )} } > ) : ( '' )} ) : ( )}
{this.state.has_range & (this.state.amount == null) ? ( ) : ( )}
} secondary={ this.state.currency == 1000 ? t('Swap destination') : t('Accepted payment methods') } /> {/* If there is live Price and Premium data, show it. Otherwise show the order maker settings */} {this.state.price_now ? ( ) : this.state.is_explicit ? ( ) : ( )} {/* if order is in a status that does not expire, do not show countdown */} {[4, 12, 13, 14, 15, 16, 17, 18].includes(this.state.status) ? null : ( <> )}
{/* If the user has a penalty/limit */} {this.state.penalty ? ( <> ) : null} {/* If the counterparty asked for collaborative cancel */} {this.state.pending_cancel ? ( <> {t('{{nickname}} is asking for a collaborative cancel', { nickname: this.state.is_maker ? this.state.taker_nick : this.state.maker_nick, })} ) : null} {/* If the user has asked for a collaborative cancel */} {this.state.asked_for_cancel ? ( <> {t('You asked for a collaborative cancellation')} ) : null}
{/* Participants can see the "Cancel" Button, but cannot see the "Back" or "Take Order" buttons */} {this.state.is_participant ? ( <> {this.CancelButton()} {this.BackButton()} ) : ( )}
); }; doubleOrderPageDesktop = () => { return ( {this.orderBox()} ); }; a11yProps(index) { return { id: `simple-tab-${index}`, 'aria-controls': `simple-tabpanel-${index}`, }; } doubleOrderPagePhone = () => { const { t } = this.props; return ( this.setState({ tabValue: 0 })} /> this.setState({ tabValue: 1 })} />
{this.orderBox()}
); }; orderDetailsPage() { const { t } = this.props; return this.state.bad_request ? (
{/* IMPLEMENT I18N for bad_request */} {t(this.state.bad_request)}
) : this.state.is_participant ? ( <> {this.weblnDialog()} {/* Desktop View */} {this.doubleOrderPageDesktop()} {/* SmarPhone View */} {this.doubleOrderPagePhone()} ) : ( {this.orderBox()} ); } handleCloseWeblnDialog = () => { this.setState({ openWeblnDialog: false }); }; weblnDialog = () => { const { t } = this.props; return ( {t('WebLN')} {this.state.waitingWebln ? ( <> {this.state.is_buyer ? t('Invoice not received, please check your WebLN wallet.') : t('Payment not received, please check your WebLN wallet.')} ) : ( <> {t('You can close now your WebLN wallet popup.')} )} ); }; render() { return ( // Only so nothing shows while requesting the first batch of data this.state.loading ? : this.orderDetailsPage() ); } } export default withTranslation()(OrderPage);