diff --git a/frontend/src/basic/BookPage/index.tsx b/frontend/src/basic/BookPage/index.tsx index d5aca5e7a..9dda32919 100644 --- a/frontend/src/basic/BookPage/index.tsx +++ b/frontend/src/basic/BookPage/index.tsx @@ -19,7 +19,9 @@ interface BookPageProps { fetchLimits: () => void; fav: Favorites; setFav: (state: Favorites) => void; + onViewOrder: () => void; fetchBook: () => void; + clearOrder: () => void; windowSize: { width: number; height: number }; lastDayPremium: number; maker: Maker; @@ -36,8 +38,10 @@ const BookPage = ({ book = { orders: [], loading: true }, fetchBook, fetchLimits, + clearOrder, fav, setFav, + onViewOrder, maker, setMaker, windowSize, @@ -79,6 +83,7 @@ const BookPage = ({ history.push('/order/' + id); setPage('order'); setCurrentOrder(id); + onViewOrder(); } else { setOpenNoRobot(true); } @@ -128,6 +133,7 @@ const BookPage = ({ setPage={setPage} hasRobot={hasRobot} onOrderCreated={(id) => { + clearOrder(); setCurrentOrder(id); setPage('order'); history.push('/order/' + id); diff --git a/frontend/src/basic/Main.tsx b/frontend/src/basic/Main.tsx index 2b171a645..b2bd1d8dd 100644 --- a/frontend/src/basic/Main.tsx +++ b/frontend/src/basic/Main.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { HashRouter, BrowserRouter, Switch, Route, useHistory } from 'react-router-dom'; +import { HashRouter, BrowserRouter, Switch, Route } from 'react-router-dom'; import { useTheme, Box, Slide, Typography } from '@mui/material'; import UserGenPage from './UserGenPage'; @@ -22,6 +22,7 @@ import { defaultMaker, defaultInfo, Coordinator, + Order, } from '../models'; import { apiClient } from '../services/api'; @@ -30,6 +31,7 @@ import { sha256 } from 'js-sha256'; import defaultCoordinators from '../../static/federation.json'; import { useTranslation } from 'react-i18next'; +import Notifications from '../components/Notifications'; const getWindowSize = function (fontSize: number) { // returns window size in EM units @@ -39,6 +41,29 @@ const getWindowSize = function (fontSize: number) { }; }; +// Refresh delays (ms) according to Order status +const statusToDelay = [ + 3000, // 'Waiting for maker bond' + 35000, // 'Public' + 180000, // 'Paused' + 3000, // 'Waiting for taker bond' + 999999, // 'Cancelled' + 999999, // 'Expired' + 8000, // 'Waiting for trade collateral and buyer invoice' + 8000, // 'Waiting only for seller trade collateral' + 8000, // 'Waiting only for buyer invoice' + 10000, // 'Sending fiat - In chatroom' + 10000, // 'Fiat sent - In chatroom' + 100000, // 'In dispute' + 999999, // 'Collaboratively cancelled' + 10000, // 'Sending satoshis to buyer' + 999999, // 'Sucessful trade' + 30000, // 'Failed lightning network routing' + 300000, // 'Wait for dispute resolution' + 300000, // 'Maker lost dispute' + 300000, // 'Taker lost dispute' +]; + interface SlideDirection { in: 'left' | 'right' | undefined; out: 'left' | 'right' | undefined; @@ -51,6 +76,7 @@ interface MainProps { const Main = ({ settings, setSettings }: MainProps): JSX.Element => { const { t } = useTranslation(); + const theme = useTheme(); // All app data structured const [book, setBook] = useState({ orders: [], loading: true }); @@ -65,8 +91,10 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { const [baseUrl, setBaseUrl] = useState(''); const [fav, setFav] = useState({ type: null, currency: 0 }); - const theme = useTheme(); - const history = useHistory(); + const [delay, setDelay] = useState(60000); + const [timer, setTimer] = useState(setInterval(() => null, delay)); + const [order, setOrder] = useState(undefined); + const [badOrder, setBadOrder] = useState(undefined); const Router = window.NativeRobosats === undefined ? BrowserRouter : HashRouter; const basename = window.NativeRobosats === undefined ? '' : window.location.pathname; @@ -77,7 +105,8 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { in: undefined, out: undefined, }); - const [currentOrder, setCurrentOrder] = useState(null); + + const [currentOrder, setCurrentOrder] = useState(undefined); const navbarHeight = 2.5; const closeAll = { @@ -100,8 +129,11 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { if (typeof window !== undefined) { window.addEventListener('resize', onResize); } - fetchBook(); - fetchLimits(); + + if (baseUrl != '') { + fetchBook(); + fetchLimits(); + } return () => { if (typeof window !== undefined) { window.removeEventListener('resize', onResize); @@ -164,11 +196,7 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { }; useEffect(() => { - if ( - open.stats || - open.coordinator || - info.version == { major: null, minor: null, patch: null } - ) { + if (open.stats || open.coordinator || info.coordinatorVersion == 'v?.?.?') { fetchInfo(); } }, [open.stats, open.coordinator]); @@ -196,7 +224,7 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { nickname: data.nickname, token: robot.token, loading: false, - avatarLoaded: robot.nickname === data.nickname ? true : false, + avatarLoaded: robot.nickname === data.nickname, activeOrderId: data.active_order_id ? data.active_order_id : null, lastOrderId: data.last_order_id ? data.last_order_id : null, referralCode: data.referral_code, @@ -215,7 +243,7 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { }; useEffect(() => { - if (baseUrl != '') { + if (baseUrl != '' && page != 'robot') { if (open.profile || (robot.token && robot.nickname === null)) { fetchRobot({ keys: false }); // fetch existing robot } else if (robot.token && robot.encPrivKey && robot.pubKey) { @@ -224,6 +252,48 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { } }, [open.profile, baseUrl]); + // Fetch current order at load and in a loop + useEffect(() => { + if (currentOrder != undefined && (page == 'order' || (order == badOrder) == undefined)) { + fetchOrder(); + } + }, [currentOrder, page]); + + useEffect(() => { + clearInterval(timer); + setTimer(setInterval(fetchOrder, delay)); + return () => clearInterval(timer); + }, [delay, currentOrder, page, badOrder]); + + const orderReceived = function (data: any) { + if (data.bad_request != undefined) { + setBadOrder(data.bad_request); + setDelay(99999999); + setOrder(undefined); + } else { + setDelay( + data.status >= 0 && data.status <= 18 + ? page == 'order' + ? statusToDelay[data.status] + : statusToDelay[data.status] * 5 + : 99999999, + ); + setOrder(data); + setBadOrder(undefined); + } + }; + + const fetchOrder = function () { + if (currentOrder != undefined) { + apiClient.get(baseUrl, '/api/order/?order_id=' + currentOrder).then(orderReceived); + } + }; + + const clearOrder = function () { + setOrder(undefined); + setBadOrder(undefined); + }; + return ( {/* load robot avatar image, set avatarLoaded: true */} @@ -233,6 +303,14 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { baseUrl={baseUrl} onLoad={() => setRobot({ ...robot, avatarLoaded: true })} /> + setOpen({ ...closeAll, profile: true })} + rewards={robot.earnedRewards} + setPage={setPage} + windowWidth={windowSize.width} + /> {settings.network === 'testnet' ? (
@@ -286,12 +364,17 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { { + setOrder(undefined); + setDelay(10000); + }} limits={limits} fetchLimits={fetchLimits} fav={fav} setFav={setFav} maker={maker} setMaker={setMaker} + clearOrder={clearOrder} lastDayPremium={info.last_day_nonkyc_btc_premium} windowSize={windowSize} hasRobot={robot.avatarLoaded} @@ -316,6 +399,7 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { fetchLimits={fetchLimits} maker={maker} setMaker={setMaker} + clearOrder={clearOrder} setPage={setPage} setCurrentOrder={setCurrentOrder} fav={fav} @@ -338,11 +422,16 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { >
@@ -366,21 +455,23 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => { - +
+ +
void; setMaker: (state: Maker) => void; + clearOrder: () => void; windowSize: { width: number; height: number }; hasRobot: boolean; setCurrentOrder: (state: number) => void; @@ -35,6 +36,7 @@ const MakerPage = ({ maker, setFav, setMaker, + clearOrder, windowSize, setCurrentOrder, setPage, @@ -76,6 +78,7 @@ const MakerPage = ({ showControls={false} showFooter={false} showNoResults={false} + baseUrl={baseUrl} /> @@ -99,6 +102,7 @@ const MakerPage = ({ maker={maker} setMaker={setMaker} onOrderCreated={(id) => { + clearOrder(); setCurrentOrder(id); setPage('order'); history.push('/order/' + id); diff --git a/frontend/src/basic/NavBar/NavBar.tsx b/frontend/src/basic/NavBar/NavBar.tsx index 4d48f71d6..7541874ac 100644 --- a/frontend/src/basic/NavBar/NavBar.tsx +++ b/frontend/src/basic/NavBar/NavBar.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useHistory } from 'react-router-dom'; -import { Tabs, Tab, Paper, useTheme, Tooltip } from '@mui/material'; +import { Tabs, Tab, Paper, useTheme } from '@mui/material'; import MoreTooltip from './MoreTooltip'; import { OpenDialogs } from '../MainDialogs'; @@ -30,7 +30,7 @@ interface NavBarProps { open: OpenDialogs; setOpen: (state: OpenDialogs) => void; closeAll: OpenDialogs; - currentOrder: number | null; + currentOrder: number | undefined; hasRobot: boolean; baseUrl: string; color: 'primary' | 'secondary'; @@ -151,7 +151,7 @@ const NavBar = ({ sx={tabSx} label={smallBar ? undefined : t('Order')} value='order' - disabled={!hasRobot || currentOrder == null} + disabled={!hasRobot || currentOrder == undefined} icon={} iconPosition='start' /> diff --git a/frontend/src/basic/OrderPage/index.js b/frontend/src/basic/OrderPage/index.js deleted file mode 100644 index d77e3d51c..000000000 --- a/frontend/src/basic/OrderPage/index.js +++ /dev/null @@ -1,1105 +0,0 @@ -import React, { Component } from 'react'; -import { withTranslation } from 'react-i18next'; -import { - TextField, - Chip, - Tooltip, - Tab, - Tabs, - Alert, - Paper, - CircularProgress, - Button, - Grid, - Typography, - List, - ListItem, - ListItemIcon, - ListItemText, - ListItemAvatar, - Divider, - Box, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from '@mui/material'; -import Countdown, { zeroPad } from 'react-countdown'; -import { StoreTokenDialog, NoRobotDialog } from '../../components/Dialogs'; - -import currencyDict from '../../../static/assets/currencies.json'; -import TradeBox from '../../components/TradeBox'; -import { FlagWithProps } from '../../components/Icons'; -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 { pn, getWebln, statusBadgeColor } from '../../utils'; -import { systemClient } from '../../services/System'; -import { apiClient } from '../../services/api'; -import RobotAvatar from '../../components/RobotAvatar'; -import { PaymentStringAsIcons } from '../../components/PaymentMethods'; - -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 }); - apiClient.get(this.props.baseUrl, '/api/order/?order_id=' + id).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) => { - apiClient - .post(this.props.baseUrl, '/api/order/?order_id=' + this.state.orderId, { - action: 'update_invoice', - invoice, - }) - .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 }); - apiClient - .post(this.props.baseUrl, '/api/order/?order_id=' + this.state.orderId, { - action: 'take', - amount: this.state.takeAmount, - }) - .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 }); - apiClient - .post(this.props.baseUrl, '/api/order/?order_id=' + this.state.orderId, { - action: 'cancel', - }) - .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 systemClient.getItem('robot_token') ? ( - this.setState({ openStoreToken: false })} - onClickCopy={() => systemClient.copyToClipboard(systemClient.getItem('robot_token'))} - 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 })} - setPage={this.props.setPage} - /> - ); - }; - - handleClickConfirmCollaborativeCancelButton = () => { - apiClient - .post(this.props.baseUrl, '/api/order/?order_id=' + this.state.orderId, { - action: 'cancel', - }) - .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; - }; - - orderBox = () => { - const { t } = this.props; - return ( - - - - - {t('Order Box')} - - - - - - - - - - - - {this.state.is_participant ? ( - <> - {this.state.taker_nick !== 'None' ? ( - <> - - - - - - - - - ) : ( - '' - )} - - - - - - - - - - - - ) : ( - - - - )} - - - -
- -
-
- {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); diff --git a/frontend/src/basic/OrderPage/index.tsx b/frontend/src/basic/OrderPage/index.tsx new file mode 100644 index 000000000..b6c753dd7 --- /dev/null +++ b/frontend/src/basic/OrderPage/index.tsx @@ -0,0 +1,208 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Tab, Tabs, Paper, CircularProgress, Grid, Typography, Box } from '@mui/material'; +import { useHistory } from 'react-router-dom'; + +import TradeBox from '../../components/TradeBox'; +import OrderDetails from '../../components/OrderDetails'; + +import { Page } from '../NavBar'; +import { Order } from '../../models'; +import { apiClient } from '../../services/api'; + +interface OrderPageProps { + windowSize: { width: number; height: number }; + order: Order; + setOrder: (state: Order) => void; + setCurrentOrder: (state: number) => void; + fetchOrder: () => void; + badOrder: string | undefined; + setBadOrder: (state: string | undefined) => void; + hasRobot: boolean; + setPage: (state: Page) => void; + baseUrl: string; + locationOrderId: number; +} + +const OrderPage = ({ + windowSize, + order, + setOrder, + setCurrentOrder, + badOrder, + setBadOrder, + setPage, + hasRobot = false, + baseUrl, + locationOrderId, +}: OrderPageProps): JSX.Element => { + const { t } = useTranslation(); + const history = useHistory(); + + const doublePageWidth: number = 50; + const maxHeight: number = windowSize.height * 0.85 - 3; + + const [tab, setTab] = useState<'order' | 'contract'>('contract'); + + useEffect(() => setCurrentOrder(locationOrderId), []); + + const renewOrder = function () { + if (order != undefined) { + const body = { + type: order.type, + currency: order.currency, + amount: order.has_range ? null : order.amount, + has_range: order.has_range, + min_amount: order.min_amount, + max_amount: order.max_amount, + payment_method: order.payment_method, + is_explicit: order.is_explicit, + premium: order.is_explicit ? null : order.premium, + satoshis: order.is_explicit ? order.satoshis : null, + public_duration: order.public_duration, + escrow_duration: order.escrow_duration, + bond_size: order.bond_size, + bondless_taker: order.bondless_taker, + }; + apiClient.post(baseUrl, '/api/make/', body).then((data: any) => { + if (data.bad_request) { + setBadOrder(data.bad_request); + } else if (data.id) { + history.push('/order/' + data.id); + setCurrentOrder(data.id); + } + }); + } + }; + + const startAgain = function () { + history.push('/robot'); + setPage('robot'); + }; + return ( + + {order == undefined && badOrder == undefined ? : null} + {badOrder != undefined ? ( + + {t(badOrder)} + + ) : null} + {order != undefined && badOrder == undefined ? ( + order.is_participant ? ( + windowSize.width > doublePageWidth ? ( + // DOUBLE PAPER VIEW + + + + + + + + + + + + + ) : ( + // SINGLE PAPER VIEW + + + setTab(value)} + variant='fullWidth' + > + + + + + +
+ +
+
+ +
+
+
+ ) + ) : ( + + + + ) + ) : ( + <> + )} +
+ ); +}; + +export default OrderPage; diff --git a/frontend/src/basic/UserGenPage.js b/frontend/src/basic/UserGenPage.js index 36e42351b..6a10f91a5 100644 --- a/frontend/src/basic/UserGenPage.js +++ b/frontend/src/basic/UserGenPage.js @@ -89,7 +89,7 @@ class UserGenPage extends Component { nickname: data.nickname ?? this.props.robot.nickname, activeOrderId: data.active_order_id ?? null, referralCode: data.referral_code ?? this.props.referralCode, - earnedRewards: data.earned_rewards ?? this.props.eartnedRewards, + earnedRewards: data.earned_rewards ?? this.props.earnedRewards, lastOrderId: data.last_order_id ?? this.props.lastOrderId, stealthInvoices: data.wants_stealth ?? this.props.stealthInvoices, tgEnabled: data.tg_enabled, @@ -99,7 +99,7 @@ class UserGenPage extends Component { : this.props.setRobot({ ...this.props.robot, nickname: data.nickname, - token: token, + token, loading: false, activeOrderId: data.active_order_id ? data.active_order_id : null, lastOrderId: data.last_order_id ? data.last_order_id : null, @@ -126,9 +126,9 @@ class UserGenPage extends Component { apiClient.delete(this.props.baseUrl, '/api/user'); systemClient.deleteCookie('sessionid'); - systemClient.deleteCookie('robot_token'); - systemClient.deleteCookie('pub_key'); - systemClient.deleteCookie('enc_priv_key'); + systemClient.deleteItem('robot_token'); + systemClient.deleteItem('pub_key'); + systemClient.deleteItem('enc_priv_key'); } handleClickNewRandomToken = () => { diff --git a/frontend/src/components/Dialogs/Profile.tsx b/frontend/src/components/Dialogs/Profile.tsx index 30e936a07..cfcb93d57 100644 --- a/frontend/src/components/Dialogs/Profile.tsx +++ b/frontend/src/components/Dialogs/Profile.tsx @@ -68,7 +68,6 @@ const ProfileDialog = ({ const host = getHost(); const [rewardInvoice, setRewardInvoice] = useState(''); - const [showRewards, setShowRewards] = useState(false); const [showRewardsSpinner, setShowRewardsSpinner] = useState(false); const [withdrawn, setWithdrawn] = useState(false); const [badInvoice, setBadInvoice] = useState(''); @@ -80,7 +79,7 @@ const ProfileDialog = ({ getWebln().then((webln) => { setWeblnEnabled(webln !== undefined); }); - }, [showRewards]); + }, []); const copyTokenHandler = () => { const robotToken = systemClient.getItem('robot_token'); @@ -328,141 +327,113 @@ const ProfileDialog = ({ - + - - - {t('Rewards and compensations')} -
- } - control={ - setShowRewards(!showRewards)} /> - } + + + + + + + ), + }} /> - {showRewards && ( - <> - - - - - - - - - - - - ), - }} - /> - - - - - - - - - {!openClaimRewards ? ( - - - - {`${robot.earnedRewards} Sats`} - - - - - - - - ) : ( -
- - - { - setRewardInvoice(e.target.value); - }} - /> - - - - + + + + + + {!openClaimRewards ? ( + + + + {`${robot.earnedRewards} Sats`} + + + + + + + + ) : ( + + + + { + setRewardInvoice(e.target.value); + }} + /> + + + + + + {weblnEnabled && ( + + + - {weblnEnabled && ( - - - - - - )} - + )} - + + )} +
- {showRewardsSpinner && ( -
- -
- )} + {showRewardsSpinner && ( +
+ +
+ )} - {withdrawn && ( -
- - {t('There it goes, thank you!šŸ„‡')} - -
- )} - + {withdrawn && ( +
+ + {t('There it goes, thank you!šŸ„‡')} + +
)} diff --git a/frontend/src/components/Icons/WorldFlags.tsx b/frontend/src/components/Icons/WorldFlags.tsx index d1b592b4d..fdcd5be0c 100644 --- a/frontend/src/components/Icons/WorldFlags.tsx +++ b/frontend/src/components/Icons/WorldFlags.tsx @@ -5,13 +5,12 @@ import { GoldIcon, EarthIcon } from '.'; interface Props { code: string; + width?: string | number; + height?: string | number; } -const FlagWithProps = ({ code }: Props): JSX.Element => { - const defaultProps = { - width: '1.428em', - height: '1.428em', - }; +const FlagWithProps = ({ code, width = '1.428em', height = '1.428em' }: Props): JSX.Element => { + const defaultProps = { width, height }; let flag: JSX.Element | null = null; diff --git a/frontend/src/components/Notifications/index.tsx b/frontend/src/components/Notifications/index.tsx new file mode 100644 index 000000000..c685fdc01 --- /dev/null +++ b/frontend/src/components/Notifications/index.tsx @@ -0,0 +1,364 @@ +import React, { useEffect, useState } from 'react'; +import { StringIfPlural, useTranslation } from 'react-i18next'; +import { + Tooltip, + Alert, + useTheme, + IconButton, + TooltipProps, + styled, + tooltipClasses, +} from '@mui/material'; +import { useHistory } from 'react-router-dom'; +import { Order } from '../../models'; +import Close from '@mui/icons-material/Close'; +import { Page } from '../../basic/NavBar'; + +interface NotificationsProps { + order: Order | undefined; + rewards: number | undefined; + page: Page; + setPage: (state: Page) => void; + openProfile: () => void; + windowWidth: number; +} + +interface NotificationMessage { + title: string; + severity: 'error' | 'warning' | 'info' | 'success'; + onClick: () => void; + sound: HTMLAudioElement | undefined; + timeout: number; + pageTitle: String; +} + +const audio = { + chat: new Audio(`/static/assets/sounds/chat-open.mp3`), + takerFound: new Audio(`/static/assets/sounds/taker-found.mp3`), + ding: new Audio(`/static/assets/sounds/locked-invoice.mp3`), + successful: new Audio(`/static/assets/sounds/successful.mp3`), +}; + +const emptyNotificationMessage: NotificationMessage = { + title: '', + severity: 'info', + onClick: () => null, + sound: undefined, + timeout: 1000, + pageTitle: 'RoboSats - Simple and Private Bitcoin Exchange', +}; + +const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + backgroundColor: 'rgb(0,0,0,0)', + boxShadow: theme.shadows[1], + borderRadius: '0.3em', + padding: '0', + }, +})); + +const Notifications = ({ + order, + rewards, + page, + setPage, + windowWidth, + openProfile, +}: NotificationsProps): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + const history = useHistory(); + + const [message, setMessage] = useState(emptyNotificationMessage); + const [inFocus, setInFocus] = useState(true); + const [titleAnimation, setTitleAnimation] = useState(undefined); + const [show, setShow] = useState(false); + + // Keep last values to trigger effects on change + const [oldOrderStatus, setOldOrderStatus] = useState(undefined); + const [oldRewards, setOldRewards] = useState(0); + const [oldChatIndex, setOldChatIndex] = useState(0); + + const position = windowWidth > 60 ? { top: '4em', right: '0em' } : { top: '0.5em', left: '50%' }; + const basePageTitle = t('RoboSats - Simple and Private Bitcoin Exchange'); + + const moveToOrderPage = function () { + setPage('order'); + history.push(`/order/${order?.id}`); + setShow(false); + }; + + interface MessagesProps { + bondLocked: NotificationMessage; + escrowLocked: NotificationMessage; + taken: NotificationMessage; + expired: NotificationMessage; + chat: NotificationMessage; + successful: NotificationMessage; + routingFailed: NotificationMessage; + dispute: NotificationMessage; + disputeWinner: NotificationMessage; + disputeLoser: NotificationMessage; + rewards: NotificationMessage; + chatMessage: NotificationMessage; + } + + const Messages: MessagesProps = { + bondLocked: { + title: t(`${order?.is_maker ? 'Maker' : 'Taker'} bond locked`), + severity: 'info', + onClick: moveToOrderPage, + sound: audio.ding, + timeout: 10000, + pageTitle: `${t('āœ… Bond!')} - ${basePageTitle}`, + }, + escrowLocked: { + title: t(`Order collateral locked`), + severity: 'info', + onClick: moveToOrderPage, + sound: audio.ding, + timeout: 10000, + pageTitle: `${t('āœ… Escrow!')} - ${basePageTitle}`, + }, + taken: { + title: t('Order has been taken!'), + severity: 'success', + onClick: moveToOrderPage, + sound: audio.takerFound, + timeout: 30000, + pageTitle: `${t('šŸ„³ Taken!')} - ${basePageTitle}`, + }, + expired: { + title: t('Order has expired'), + severity: 'warning', + onClick: moveToOrderPage, + sound: undefined, + timeout: 30000, + pageTitle: `${t('šŸ˜Ŗ Expired!')} - ${basePageTitle}`, + }, + chat: { + title: t('Order chat is open'), + severity: 'info', + onClick: moveToOrderPage, + sound: audio.chat, + timeout: 30000, + pageTitle: `${t('šŸ’¬ Chat!')} - ${basePageTitle}`, + }, + successful: { + title: t('Trade finished successfully!'), + severity: 'success', + onClick: moveToOrderPage, + sound: audio.successful, + timeout: 10000, + pageTitle: `${t('šŸ™Œ Funished!')} - ${basePageTitle}`, + }, + routingFailed: { + title: t('Lightning routing failed'), + severity: 'warning', + onClick: moveToOrderPage, + sound: audio.ding, + timeout: 20000, + pageTitle: `${t('ā—āš” Routing Failed')} - ${basePageTitle}`, + }, + dispute: { + title: t('Order has been disputed'), + severity: 'warning', + onClick: moveToOrderPage, + sound: audio.ding, + timeout: 40000, + pageTitle: `${t('āš–ļø Disputed!')} - ${basePageTitle}`, + }, + disputeWinner: { + title: t('You won the dispute'), + severity: 'success', + onClick: moveToOrderPage, + sound: audio.ding, + timeout: 30000, + pageTitle: `${t('šŸ‘ dispute')} - ${basePageTitle}`, + }, + disputeLoser: { + title: t('You lost the dispute'), + severity: 'error', + onClick: moveToOrderPage, + sound: audio.ding, + timeout: 30000, + pageTitle: `${t('šŸ‘Ž dispute')} - ${basePageTitle}`, + }, + rewards: { + title: t('You can claim Sats!'), + severity: 'success', + onClick: () => { + openProfile(); + setShow(false); + }, + sound: audio.ding, + timeout: 300000, + pageTitle: `${t('ā‚æ Rewards!')} - ${basePageTitle}`, + }, + chatMessage: { + title: t('New chat message'), + severity: 'info', + onClick: moveToOrderPage, + sound: audio.chat, + timeout: 3000, + pageTitle: `${t('šŸ’¬ message!')} - ${basePageTitle}`, + }, + }; + + const notify = function (message: NotificationMessage) { + if (message.title != '') { + setMessage(message); + setShow(true); + setTimeout(() => setShow(false), message.timeout); + if (message.sound) { + message.sound.play(); + } + if (!inFocus) { + setTitleAnimation( + setInterval(function () { + var title = document.title; + document.title = title == basePageTitle ? message.pageTitle : basePageTitle; + }, 1000), + ); + } + } + }; + + const handleStatusChange = function (oldStatus: number | undefined, status: number) { + let message = emptyNotificationMessage; + + // Order status descriptions: + // 0: 'Waiting for maker bond' + // 1: 'Public' + // 2: 'Paused' + // 3: 'Waiting for taker bond' + // 5: 'Expired' + // 6: 'Waiting for trade collateral and buyer invoice' + // 7: 'Waiting only for seller trade collateral' + // 8: 'Waiting only for buyer invoice' + // 9: 'Sending fiat - In chatroom' + // 10: 'Fiat sent - In chatroom' + // 11: 'In dispute' + // 12: 'Collaboratively cancelled' + // 13: 'Sending satoshis to buyer' + // 14: 'Sucessful trade' + // 15: 'Failed lightning network routing' + // 16: 'Wait for dispute resolution' + // 17: 'Maker lost dispute' + // 18: 'Taker lost dispute' + + if (status == 5 && oldStatus != 5) { + message = Messages.expired; + } else if (oldStatus == undefined) { + message = emptyNotificationMessage; + } else if (order?.is_maker && status > 0 && oldStatus == 0) { + message = Messages.bondLocked; + } else if (order?.is_taker && status > 5 && oldStatus <= 5) { + message = Messages.bondLocked; + } else if (order?.is_maker && status > 5 && oldStatus <= 5) { + message = Messages.taken; + } else if (order?.is_seller && status > 7 && oldStatus < 7) { + message = Messages.escrowLocked; + } else if ([9, 10].includes(status) && oldStatus < 9) { + console.log('yoooo'); + message = Messages.chat; + } else if (order?.is_seller && [13, 14, 15].includes(status) && oldStatus < 13) { + message = Messages.successful; + } else if (order?.is_buyer && status == 14 && oldStatus != 14) { + message = Messages.successful; + } else if (order?.is_buyer && status == 15 && oldStatus < 14) { + message = Messages.routingFailed; + } else if (status == 11 && oldStatus < 11) { + message = Messages.dispute; + } else if (status == 11 && oldStatus < 11) { + message = Messages.dispute; + } else if ( + ((order?.is_maker && status == 18) || (order?.is_taker && status == 17)) && + oldStatus < 17 + ) { + message = Messages.disputeWinner; + } else if ( + ((order?.is_maker && status == 17) || (order?.is_taker && status == 18)) && + oldStatus < 17 + ) { + message = Messages.disputeLoser; + } + + notify(message); + }; + + // Notify on order status change + useEffect(() => { + if (order != undefined && order.status != oldOrderStatus) { + handleStatusChange(oldOrderStatus, order.status); + setOldOrderStatus(order.status); + } else if (order != undefined && order.chat_last_index > oldChatIndex) { + if (page != 'order') { + notify(Messages.chatMessage); + } + setOldChatIndex(order.chat_last_index); + } + }, [order]); + + // Notify on rewards change + useEffect(() => { + if (rewards != undefined) { + if (rewards > oldRewards) { + notify(Messages.rewards); + } + setOldRewards(rewards); + } + }, [rewards]); + + // Set blinking page title and clear on visibility change > infocus + useEffect(() => { + if (titleAnimation != undefined && inFocus) { + clearInterval(titleAnimation); + } + }, [inFocus]); + + useEffect(() => { + document.addEventListener('visibilitychange', function () { + if (document.hidden) { + setInFocus(false); + } else if (!document.hidden) { + setInFocus(true); + document.title = basePageTitle; + } + }); + }, []); + + return ( + 60 ? 'left' : 'bottom'} + title={ + { + setShow(false); + }} + > + + + } + > +
+ {message.title} +
+
+ } + > +
+ + ); +}; + +export default Notifications; diff --git a/frontend/src/basic/OrderPage/LinearDeterminate.tsx b/frontend/src/components/OrderDetails/LinearDeterminate.tsx similarity index 75% rename from frontend/src/basic/OrderPage/LinearDeterminate.tsx rename to frontend/src/components/OrderDetails/LinearDeterminate.tsx index 747cc24f8..ff7ab2dd6 100644 --- a/frontend/src/basic/OrderPage/LinearDeterminate.tsx +++ b/frontend/src/components/OrderDetails/LinearDeterminate.tsx @@ -8,7 +8,7 @@ interface Props { } const LinearDeterminate = ({ expiresAt, totalSecsExp }: Props): JSX.Element => { - const [progress, setProgress] = useState(0); + const [progress, setProgress] = useState(100); useEffect(() => { const timer = setInterval(() => { @@ -25,7 +25,12 @@ const LinearDeterminate = ({ expiresAt, totalSecsExp }: Props): JSX.Element => { return ( - + ); }; diff --git a/frontend/src/components/OrderDetails/TakeButton.tsx b/frontend/src/components/OrderDetails/TakeButton.tsx new file mode 100644 index 000000000..a13224e04 --- /dev/null +++ b/frontend/src/components/OrderDetails/TakeButton.tsx @@ -0,0 +1,294 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogTitle, + DialogContentText, + DialogActions, + DialogContent, + Box, + Button, + Tooltip, + Grid, + TextField, + useTheme, + Typography, +} from '@mui/material'; + +import Countdown from 'react-countdown'; + +import currencies from '../../../static/assets/currencies.json'; +import { apiClient } from '../../services/api'; + +import { Order } from '../../models'; +import { ConfirmationDialog } from '../Dialogs'; +import { Page } from '../../basic/NavBar'; +import { LoadingButton } from '@mui/lab'; + +interface TakeButtonProps { + order: Order; + setOrder: (state: Order) => void; + baseUrl: string; + hasRobot: boolean; + setPage: (state: Page) => void; +} + +interface OpenDialogsProps { + inactiveMaker: boolean; + confirmation: boolean; +} +const closeAll = { inactiveMaker: false, confirmation: false }; + +const TakeButton = ({ + order, + setOrder, + baseUrl, + setPage, + hasRobot, +}: TakeButtonProps): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + + const [takeAmount, setTakeAmount] = useState(''); + const [badRequest, setBadRequest] = useState(''); + const [loadingTake, setLoadingTake] = useState(false); + const [open, setOpen] = useState(closeAll); + + const currencyCode: string = currencies[`${order.currency}`]; + + const InactiveMakerDialog = function () { + return ( + setOpen({ ...open, inactiveMaker: false })}> + {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.', + )} + + + + + + + + ); + }; + + const countdownTakeOrderRenderer = function ({ seconds, completed }) { + if (isNaN(seconds) || completed) { + return takeOrderButton(); + } else { + return ( + + + + {t('Take Order')} + + + + ); + } + }; + + const handleTakeAmountChange = function (e) { + if (e.target.value != '' && e.target.value != null) { + setTakeAmount(`${parseFloat(e.target.value)}`); + } else { + setTakeAmount(e.target.value); + } + }; + + const amountHelperText = function () { + if (Number(takeAmount) < Number(order.min_amount) && takeAmount != '') { + return t('Too low'); + } else if (Number(takeAmount) > Number(order.max_amount) && takeAmount != '') { + return t('Too high'); + } else { + return null; + } + }; + + const onTakeOrderClicked = function () { + if (order.maker_status == 'Inactive') { + setOpen({ inactiveMaker: true, confirmation: false }); + } else { + setOpen({ inactiveMaker: false, confirmation: true }); + } + }; + + const invalidTakeAmount = function () { + return ( + Number(takeAmount) < Number(order.min_amount) || + Number(takeAmount) > Number(order.max_amount) || + takeAmount == '' || + takeAmount == null + ); + }; + + const takeOrderButton = function () { + if (order.has_range) { + return ( + + + + + Number(order.max_amount)) && + takeAmount != '' + } + helperText={amountHelperText()} + label={t('Amount {{currencyCode}}', { currencyCode })} + size='small' + type='number' + required={true} + value={takeAmount} + inputProps={{ + min: order.min_amount, + max: order.max_amount, + style: { textAlign: 'center' }, + }} + onChange={handleTakeAmountChange} + /> + + + +
+ +
+ + {t('Take Order')} + +
+
+
+
+ + {t('Take Order')} + +
+
+
+
+ ); + } else { + return ( + + + {t('Take Order')} + + + ); + } + }; + + const takeOrder = function () { + setLoadingTake(true); + apiClient + .post(baseUrl, '/api/order/?order_id=' + order.id, { + action: 'take', + amount: takeAmount, + }) + .then((data) => { + setLoadingTake(false); + if (data.bad_request) { + setBadRequest(data.bad_request); + } else { + setOrder(data); + setBadRequest(''); + } + }); + }; + + return ( + + + {badRequest != '' ? ( + + + {t(badRequest)} + + + ) : ( + <> + )} + + setOpen({ ...open, confirmation: false })} + setPage={setPage} + onClickDone={() => { + takeOrder(); + setLoadingTake(true); + setOpen(closeAll); + }} + hasRobot={hasRobot} + /> + + + ); +}; + +export default TakeButton; diff --git a/frontend/src/components/OrderDetails/index.tsx b/frontend/src/components/OrderDetails/index.tsx new file mode 100644 index 000000000..91ca6b29a --- /dev/null +++ b/frontend/src/components/OrderDetails/index.tsx @@ -0,0 +1,334 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + List, + ListItem, + Alert, + Chip, + ListItemAvatar, + ListItemText, + ListItemIcon, + Divider, + Grid, + Collapse, + useTheme, +} from '@mui/material'; + +import Countdown, { CountdownRenderProps, zeroPad } from 'react-countdown'; +import RobotAvatar from '../../components/RobotAvatar'; + +import currencies from '../../../static/assets/currencies.json'; +import { + AccessTime, + Numbers, + PriceChange, + Payments, + Article, + HourglassTop, +} from '@mui/icons-material'; +import { PaymentStringAsIcons } from '../../components/PaymentMethods'; +import { FlagWithProps } from '../Icons'; +import LinearDeterminate from './LinearDeterminate'; + +import { Order } from '../../models'; +import { statusBadgeColor, pn } from '../../utils'; +import { Page } from '../../basic/NavBar'; +import TakeButton from './TakeButton'; + +interface OrderDetailsProps { + order: Order; + setOrder: (state: Order) => void; + baseUrl: string; + hasRobot: boolean; + setPage: (state: Page) => void; +} + +const OrderDetails = ({ + order, + setOrder, + baseUrl, + setPage, + hasRobot, +}: OrderDetailsProps): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + + const currencyCode: string = currencies[`${order.currency}`]; + + const AmountString = function () { + // precision to 8 decimal if currency is BTC otherwise 4 decimals + const precision = order.currency == 1000 ? 8 : 4; + + let primary = ''; + let secondary = ''; + if (order.has_range && order.amount == null) { + const minAmount = pn(parseFloat(Number(order.min_amount).toPrecision(precision))); + const maxAmount = pn(parseFloat(Number(order.max_amount).toPrecision(precision))); + primary = `${minAmount}-${maxAmount} ${currencyCode}`; + secondary = t('Amount range'); + } else { + const amount = pn(parseFloat(Number(order.amount).toPrecision(precision))); + primary = `${amount} ${currencyCode}`; + secondary = t('Amount'); + } + return { primary, secondary }; + }; + + // Countdown Renderer callback with condition + const countdownRenderer = function ({ + total, + hours, + minutes, + seconds, + completed, + }: CountdownRenderProps) { + if (completed) { + // Render a completed state + return {t('The order has expired')}; + } else { + let col = 'inherit'; + const fraction_left = total / 1000 / order.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 `} + ); + } + }; + + const timerRenderer = function (seconds: number) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds - hours * 3600) / 60); + return ( + + {hours > 0 ? hours + 'h' : ''} {minutes > 0 ? zeroPad(minutes) + 'm' : ''}{' '} + + ); + }; + + // Countdown Renderer callback with condition + const countdownPenaltyRenderer = function ({ minutes, seconds, completed }) { + 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), + })}{' '} + + ); + } + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ +
+
+ +
+ + + + + + + + } + secondary={ + order.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 */} + + + + + + {order.price_now !== undefined ? ( + + ) : null} + + {!order.price_now && order.is_explicit ? ( + + ) : null} + + {!order.price_now && !order.is_explicit ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* if order is in a status that does not expire, do not show countdown */} + + + + + + + + + + + + + + + {/* If the user has a penalty/limit */} + {order.penalty !== undefined ? ( + + + + + + ) : ( + <> + )} + + {!order.is_participant ? ( + + + + ) : ( + <> + )} + + + ); +}; + +export default OrderDetails; diff --git a/frontend/src/components/RobotAvatar/index.tsx b/frontend/src/components/RobotAvatar/index.tsx index 14ef140a8..a85a1b5b1 100644 --- a/frontend/src/components/RobotAvatar/index.tsx +++ b/frontend/src/components/RobotAvatar/index.tsx @@ -7,7 +7,7 @@ import { apiClient } from '../../services/api'; import placeholder from './placeholder.json'; interface Props { - nickname: string | null; + nickname: string | undefined; smooth?: boolean; flipHorizontally?: boolean; style?: object; @@ -40,7 +40,7 @@ const RobotAvatar: React.FC = ({ const [avatarSrc, setAvatarSrc] = useState(); useEffect(() => { - if (nickname != null) { + if (nickname != undefined) { if (window.NativeRobosats === undefined) { setAvatarSrc(baseUrl + '/static/assets/avatars/' + nickname + '.png'); } else { diff --git a/frontend/src/components/SettingsForm/SelectLanguage.tsx b/frontend/src/components/SettingsForm/SelectLanguage.tsx index 042dcfc01..dd8eae5d6 100644 --- a/frontend/src/components/SettingsForm/SelectLanguage.tsx +++ b/frontend/src/components/SettingsForm/SelectLanguage.tsx @@ -7,19 +7,19 @@ import Flags from 'country-flag-icons/react/3x2'; import { CataloniaFlag, BasqueCountryFlag } from '../Icons'; const menuLanuguages = [ - { name: 'English', i18nCode: 'en', flag: Flags['US'] }, - { name: 'EspaƱol', i18nCode: 'es', flag: Flags['ES'] }, - { name: 'Deutsch', i18nCode: 'de', flag: Flags['DE'] }, - { name: 'Polski', i18nCode: 'pl', flag: Flags['PL'] }, - { name: 'FranƧais', i18nCode: 'fr', flag: Flags['FR'] }, - { name: 'Š ŃƒŃŃŠŗŠøŠ¹', i18nCode: 'ru', flag: Flags['RU'] }, - { name: 'Italiano', i18nCode: 'it', flag: Flags['IT'] }, - { name: 'PortuguĆŖs', i18nCode: 'pt', flag: Flags['BR'] }, - { name: 'ē®€ä½“', i18nCode: 'zh-si', flag: Flags['CN'] }, - { name: 'ē¹é«”', i18nCode: 'zh-tr', flag: Flags['CN'] }, - { name: 'Svenska', i18nCode: 'sv', flag: Flags['SE'] }, - { name: 'ČeÅ”tina', i18nCode: 'cs', flag: Flags['CZ'] }, - { name: 'ąø ąø²ąø©ąø²ą¹„ąø—ąø¢', i18nCode: 'th', flag: Flags['TH'] }, + { name: 'English', i18nCode: 'en', flag: Flags.US }, + { name: 'EspaƱol', i18nCode: 'es', flag: Flags.ES }, + { name: 'Deutsch', i18nCode: 'de', flag: Flags.DE }, + { name: 'Polski', i18nCode: 'pl', flag: Flags.PL }, + { name: 'FranƧais', i18nCode: 'fr', flag: Flags.FR }, + { name: 'Š ŃƒŃŃŠŗŠøŠ¹', i18nCode: 'ru', flag: Flags.RU }, + { name: 'Italiano', i18nCode: 'it', flag: Flags.IT }, + { name: 'PortuguĆŖs', i18nCode: 'pt', flag: Flags.BR }, + { name: 'ē®€ä½“', i18nCode: 'zh-si', flag: Flags.CN }, + { name: 'ē¹é«”', i18nCode: 'zh-tr', flag: Flags.CN }, + { name: 'Svenska', i18nCode: 'sv', flag: Flags.SE }, + { name: 'ČeÅ”tina', i18nCode: 'cs', flag: Flags.CZ }, + { name: 'ąø ąø²ąø©ąø²ą¹„ąø—ąø¢', i18nCode: 'th', flag: Flags.TH }, { name: 'CatalĆ ', i18nCode: 'ca', flag: CataloniaFlag }, { name: 'Euskara', i18nCode: 'eu', flag: BasqueCountryFlag }, ]; diff --git a/frontend/src/components/TradeBox/BondStatus.tsx b/frontend/src/components/TradeBox/BondStatus.tsx index e85342621..60bef911e 100644 --- a/frontend/src/components/TradeBox/BondStatus.tsx +++ b/frontend/src/components/TradeBox/BondStatus.tsx @@ -1,42 +1,44 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Box, Typography } from '@mui/material'; +import { Typography, useTheme } from '@mui/material'; import { Lock, LockOpen, Balance } from '@mui/icons-material'; interface BondStatusProps { - status: 'locked' | 'settled' | 'returned' | 'hide'; + status: 'locked' | 'settled' | 'unlocked' | 'hide'; isMaker: boolean; } const BondStatus = ({ status, isMaker }: BondStatusProps): JSX.Element => { const { t } = useTranslation(); + const theme = useTheme(); let Icon = Lock; - if (status === 'returned') { + let color = 'primary'; + if (status === 'unlocked') { Icon = LockOpen; + color = theme.palette.mode == 'dark' ? 'lightgreen' : 'green'; } else if (status === 'settled') { Icon = Balance; + color = theme.palette.mode == 'dark' ? 'lightred' : 'red'; } if (status === 'hide') { return <>; } else { return ( - - -
- - {t(`Your ${isMaker ? 'maker' : 'taker'} bond is ${status}`)} -
-
-
+ +
+ + {t(`Your ${isMaker ? 'maker' : 'taker'} bond is ${status}`)} +
+
); } }; diff --git a/frontend/src/components/TradeBox/CancelButton.tsx b/frontend/src/components/TradeBox/CancelButton.tsx new file mode 100644 index 000000000..c08d5e0c8 --- /dev/null +++ b/frontend/src/components/TradeBox/CancelButton.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Box, Tooltip } from '@mui/material'; +import { Order } from '../../models'; +import { LoadingButton } from '@mui/lab'; + +interface CancelButtonProps { + order: Order; + onClickCancel: () => void; + openCancelDialog: () => void; + openCollabCancelDialog: () => void; + loading: boolean; +} + +const CancelButton = ({ + order, + onClickCancel, + openCancelDialog, + openCollabCancelDialog, + loading = false, +}: CancelButtonProps): JSX.Element => { + const { t } = useTranslation(); + + const showCancelButton = + (order.is_maker && [0, 1, 2].includes(order.status)) || [3, 6, 7].includes(order.status); + const showCollabCancelButton = [8, 9].includes(order.status) && !order.asked_for_cancel; + const noConfirmation = + (order.is_maker && [0, 1, 2].includes(order.status)) || (order.is_taker && order.status === 3); + + return ( + + {showCancelButton ? ( + +
+ + {t('Cancel')} + +
+
+ ) : ( + <> + )} + {showCollabCancelButton ? ( + + {t('Collaborative Cancel')} + + ) : ( + <> + )} +
+ ); +}; + +export default CancelButton; diff --git a/frontend/src/components/TradeBox/CollabCancelAlert.tsx b/frontend/src/components/TradeBox/CollabCancelAlert.tsx new file mode 100644 index 000000000..adc1defd7 --- /dev/null +++ b/frontend/src/components/TradeBox/CollabCancelAlert.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert } from '@mui/material'; +import { Order } from '../../models'; + +interface CollabCancelAlertProps { + order: Order; +} + +const CollabCancelAlert = ({ order }: CollabCancelAlertProps): JSX.Element => { + const { t } = useTranslation(); + let text = ''; + if (order.pending_cancel) { + text = t('{{nickname}} is asking for a collaborative cancel', { + nickname: order.is_maker ? order.taker_nick : order.maker_nick, + }); + } else if (order.asked_for_cancel) { + text = t('You asked for a collaborative cancellation'); + } + + return text != '' ? ( + + {text} + + ) : ( + <> + ); +}; + +export default CollabCancelAlert; diff --git a/frontend/src/components/TradeBox/Dialogs/ConfirmCancel.tsx b/frontend/src/components/TradeBox/Dialogs/ConfirmCancel.tsx new file mode 100644 index 000000000..c81bca7f3 --- /dev/null +++ b/frontend/src/components/TradeBox/Dialogs/ConfirmCancel.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogTitle, + DialogActions, + DialogContent, + DialogContentText, + Button, +} from '@mui/material'; + +interface ConfirmCancelDialogProps { + open: boolean; + onClose: () => void; + onCancelClick: () => void; +} + +export const ConfirmCancelDialog = ({ + open, + onClose, + onCancelClick, +}: ConfirmCancelDialogProps): JSX.Element => { + const { t } = useTranslation(); + + return ( + + {t('Cancel the order?')} + + + {t('If the order is cancelled now you will lose your bond.')} + + + + + + + + ); +}; + +export default ConfirmCancelDialog; diff --git a/frontend/src/components/TradeBox/Dialogs/ConfirmCollabCancel.tsx b/frontend/src/components/TradeBox/Dialogs/ConfirmCollabCancel.tsx new file mode 100644 index 000000000..bf7af5d16 --- /dev/null +++ b/frontend/src/components/TradeBox/Dialogs/ConfirmCollabCancel.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogTitle, + DialogActions, + DialogContent, + DialogContentText, + Button, +} from '@mui/material'; +import { LoadingButton } from '@mui/lab'; + +interface ConfirmCollabCancelDialogProps { + open: boolean; + loading: Boolean; + onClose: () => void; + onCollabCancelClick: () => void; + peerAskedCancel: boolean; +} + +export const ConfirmCollabCancelDialog = ({ + open, + loading, + onClose, + onCollabCancelClick, + peerAskedCancel, +}: ConfirmCollabCancelDialogProps): JSX.Element => { + const { t } = useTranslation(); + + 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.', + )} + {peerAskedCancel ? ` ${t('Your peer has asked for cancellation')}` : ''} + + + + + + {peerAskedCancel ? t('Accept Cancelation') : t('Ask for Cancel')} + + + + ); +}; + +export default ConfirmCollabCancelDialog; diff --git a/frontend/src/components/TradeBox/Dialogs/WebLN.tsx b/frontend/src/components/TradeBox/Dialogs/WebLN.tsx new file mode 100644 index 000000000..dd09dac3e --- /dev/null +++ b/frontend/src/components/TradeBox/Dialogs/WebLN.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Dialog, + DialogTitle, + DialogActions, + DialogContent, + DialogContentText, + Button, + useTheme, + CircularProgress, +} from '@mui/material'; + +import { Check } from '@mui/icons-material'; + +interface WebLNDialogProps { + open: boolean; + onClose: () => void; + waitingWebln: boolean; + isBuyer: boolean; +} + +export const WebLNDialog = ({ + open, + onClose, + waitingWebln, + isBuyer, +}: WebLNDialogProps): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + + {t('WebLN')} + + + {waitingWebln ? ( + <> + + {isBuyer + ? 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.')} + + )} + + + + + + + ); +}; + +export default WebLNDialog; diff --git a/frontend/src/components/TradeBox/Dialogs/index.ts b/frontend/src/components/TradeBox/Dialogs/index.ts index 1effc88fc..381d948a5 100644 --- a/frontend/src/components/TradeBox/Dialogs/index.ts +++ b/frontend/src/components/TradeBox/Dialogs/index.ts @@ -1,2 +1,5 @@ export { ConfirmDisputeDialog } from './ConfirmDispute'; export { ConfirmFiatReceivedDialog } from './ConfirmFiatReceived'; +export { ConfirmCancelDialog } from './ConfirmCancel'; +export { ConfirmCollabCancelDialog } from './ConfirmCollabCancel'; +export { WebLNDialog } from './WebLN'; diff --git a/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx index 7645f742e..dcc49f55d 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx @@ -18,8 +18,15 @@ const ChatBottom: React.FC = ({ orderId, setAudit, audit, createJsonFile const theme = useTheme(); return ( - <> - + + = ({ orderId, setAudit, audit, createJsonFile title={t('Verify your privacy')} > - + {window.ReactNativeWebView === undefined ? ( = ({ orderId, setAudit, audit, createJsonFile )} - + ); }; diff --git a/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx index 5a0a07fe2..968d6daf8 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx @@ -1,39 +1,86 @@ import React from 'react'; -import { Grid, Paper, Typography, useTheme } from '@mui/material'; +import { Grid, Paper, Tooltip, IconButton, Typography, useTheme } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { WifiTetheringError } from '@mui/icons-material'; interface Props { connected: boolean; peerConnected: boolean; + turtleMode: boolean; + setTurtleMode: (state: boolean) => void; } -const ChatHeader: React.FC = ({ connected, peerConnected }) => { +const ChatHeader: React.FC = ({ connected, peerConnected, turtleMode, setTurtleMode }) => { const { t } = useTranslation(); const theme = useTheme(); const connectedColor = theme.palette.mode === 'light' ? '#b5e3b7' : '#153717'; const connectedTextColor = theme.palette.getContrastText(connectedColor); return ( - - - - - + + + + {t('You') + ': '} {connected ? t('connected') : t('disconnected')} - - - - + + {window.ReactNativeWebView === undefined ? ( + + + setTurtleMode(!turtleMode)} + > + + + + + ) : ( + <> + )} + + + + {t('Peer') + ': '} {peerConnected ? t('connected') : t('disconnected')} - ); }; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx index 4c2354436..9f6114546 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx @@ -18,20 +18,26 @@ import ChatBottom from '../ChatBottom'; interface Props { orderId: number; + status: number; userNick: string; takerNick: string; messages: EncryptedChatMessage[]; setMessages: (messages: EncryptedChatMessage[]) => void; baseUrl: string; + turtleMode: boolean; + setTurtleMode: (state: boolean) => void; } const EncryptedSocketChat: React.FC = ({ orderId, + status, userNick, takerNick, messages, setMessages, baseUrl, + turtleMode, + setTurtleMode, }: Props): JSX.Element => { const { t } = useTranslation(); const theme = useTheme(); @@ -62,6 +68,13 @@ const EncryptedSocketChat: React.FC = ({ } }, [connected]); + // Make sure to not keep reconnecting once status is not Chat + useEffect(() => { + if (![9, 10].includes(status)) { + connection?.close(); + } + }, [status]); + useEffect(() => { if (messages.length > messageCount) { audio.play(); @@ -99,7 +112,7 @@ const EncryptedSocketChat: React.FC = ({ encrypted_private_key: ownEncPrivKey, passphrase: token, }, - messages: messages, + messages, }; }; @@ -111,7 +124,7 @@ const EncryptedSocketChat: React.FC = ({ setPeerConnected(dataFromServer.peer_connected); // If we receive a public key other than ours (our peer key!) if ( - connection && + connection != null && dataFromServer.message.substring(0, 36) == `-----BEGIN PGP PUBLIC KEY BLOCK-----` && dataFromServer.message != ownPubKey ) { @@ -158,7 +171,7 @@ const EncryptedSocketChat: React.FC = ({ const existingMessage = prev.find( (item) => item.plainTextMessage === dataFromServer.message, ); - if (existingMessage) { + if (existingMessage != null) { return prev; } else { return [ @@ -179,14 +192,14 @@ const EncryptedSocketChat: React.FC = ({ }; const onButtonClicked = (e: any) => { - if (token && value.indexOf(token) !== -1) { + if (token && value.includes(token)) { alert( `Aye! You just sent your own robot token to your peer in chat, that's a catastrophic idea! So bad your message was blocked.`, ); setValue(''); } // If input string contains '#' send unencrypted and unlogged message - else if (connection && value.substring(0, 1) == '#') { + else if (connection != null && value.substring(0, 1) == '#') { connection.send({ message: value, nick: userNick, @@ -201,7 +214,7 @@ const EncryptedSocketChat: React.FC = ({ setLastSent(value); encryptMessage(value, ownPubKey, peerPubKey, ownEncPrivKey, token).then( (encryptedMessage) => { - if (connection) { + if (connection != null) { connection.send({ message: encryptedMessage.toString().split('\n').join('\\'), nick: userNick, @@ -214,15 +227,37 @@ const EncryptedSocketChat: React.FC = ({ }; return ( - - -
+ + setAudit(false)} + orderId={Number(orderId)} + messages={messages} + own_pub_key={ownPubKey || ''} + own_enc_priv_key={ownEncPrivKey || ''} + peer_pub_key={peerPubKey || 'Not received yet'} + passphrase={token || ''} + onClickBack={() => setAudit(false)} + /> + + = ({ />
- - + + = ({ onChange={(e) => { setValue(e.target.value); }} - sx={{ width: '13.7em' }} + fullWidth={true} /> - +
- -
- - - setAudit(false)} - orderId={Number(orderId)} - messages={messages} - own_pub_key={ownPubKey || ''} - own_enc_priv_key={ownEncPrivKey || ''} - peer_pub_key={peerPubKey || 'Not received yet'} - passphrase={token || ''} - onClickBack={() => setAudit(false)} - /> - + + = ({ createJsonFile={createJsonFile} /> - + ); }; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx index 374606a0f..8edcc7b03 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx @@ -23,6 +23,8 @@ interface Props { messages: EncryptedChatMessage[]; setMessages: (messages: EncryptedChatMessage[]) => void; baseUrl: string; + turtleMode: boolean; + setTurtleMode: (state: boolean) => void; } const EncryptedTurtleChat: React.FC = ({ @@ -33,6 +35,8 @@ const EncryptedTurtleChat: React.FC = ({ messages, setMessages, baseUrl, + setTurtleMode, + turtleMode, }: Props): JSX.Element => { const { t } = useTranslation(); const theme = useTheme(); @@ -94,7 +98,7 @@ const EncryptedTurtleChat: React.FC = ({ encrypted_private_key: ownEncPrivKey, passphrase: token, }, - messages: messages, + messages, }; }; @@ -112,7 +116,7 @@ const EncryptedTurtleChat: React.FC = ({ setLastIndex(lastIndex < dataFromServer.index ? dataFromServer.index : lastIndex); setMessages((prev: EncryptedChatMessage[]) => { const existingMessage = prev.find((item) => item.index === dataFromServer.index); - if (existingMessage) { + if (existingMessage != null) { return prev; } else { return [ @@ -158,7 +162,7 @@ const EncryptedTurtleChat: React.FC = ({ }; const onButtonClicked = (e: any) => { - if (token && value.indexOf(token) !== -1) { + if (token && value.includes(token)) { alert( `Aye! You just sent your own robot token to your peer in chat, that's a catastrophic idea! So bad your message was blocked.`, ); @@ -172,9 +176,9 @@ const EncryptedTurtleChat: React.FC = ({ offset: lastIndex, }) .then((response) => { - if (response) { - setPeerConnected(response.peer_connected); + if (response != null) { if (response.messages) { + setPeerConnected(response.peer_connected); setServerMessages(response.messages); } } @@ -197,7 +201,7 @@ const EncryptedTurtleChat: React.FC = ({ offset: lastIndex, }) .then((response) => { - if (response) { + if (response != null) { setPeerConnected(response.peer_connected); if (response.messages) { setServerMessages(response.messages); @@ -215,15 +219,38 @@ const EncryptedTurtleChat: React.FC = ({ }; return ( - - -
+ + setAudit(false)} + orderId={Number(orderId)} + messages={messages} + own_pub_key={ownPubKey || ''} + own_enc_priv_key={ownEncPrivKey || ''} + peer_pub_key={peerPubKey || 'Not received yet'} + passphrase={token || ''} + onClickBack={() => setAudit(false)} + /> + + + = ({ />
- - + + = ({ onChange={(e) => { setValue(e.target.value); }} - sx={{ width: '13.7em' }} + fullWidth={true} /> - + - ); - }; - const depositHoursMinutes = function () { const hours = Math.floor(order.escrow_duration / 3600); const minutes = Math.floor((order.escrow_duration - hours * 3600) / 60); @@ -64,24 +49,50 @@ export const LockInvoicePrompt = ({ order, concept }: LockInvoicePromptProps): J }; return ( - + + {order.is_taker && concept == 'bond' ? ( + + {t(`You are ${order.is_buyer ? 'BUYING' : 'SELLING'} BTC`)} + + ) : ( + <> + )} + - {concept === 'bond' ? : } + {concept === 'bond' ? : } - { - systemClient.copyToClipboard(invoice); + + > + { + systemClient.copyToClipboard(invoice); + }} + /> + - + + + + + {order.is_maker ? ( + + + + {t('Renew')} + + + ) : null} + + + {order.platform_summary ? ( + + + + ) : ( + <> + )} + + ); +}; + +export default SuccessfulPrompt; diff --git a/frontend/src/components/TradeBox/Prompts/TakerFound.tsx b/frontend/src/components/TradeBox/Prompts/TakerFound.tsx index cce218704..d01efb2b9 100644 --- a/frontend/src/components/TradeBox/Prompts/TakerFound.tsx +++ b/frontend/src/components/TradeBox/Prompts/TakerFound.tsx @@ -1,35 +1,21 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Grid, Typography } from '@mui/material'; -import { Order } from '../../../models'; -import stepXofY from '../stepXofY'; +import { Divider, List, ListItem, Typography } from '@mui/material'; -interface TakerFoundPrompProps { - order: Order; -} - -export const TakerFoundPrompt = ({ order }: TakerFoundPrompProps): JSX.Element => { +export const TakerFoundPrompt = (): JSX.Element => { const { t } = useTranslation(); - const Title = function () { - return ( - - {t('A taker has been found!')} - {` ${stepXofY(order)}`} - - ); - }; - return ( - - + + + {t( 'Please wait for the taker to lock a bond. If the taker does not lock a bond in time, the order will be made public again.', )} - - + + ); }; diff --git a/frontend/src/components/TradeBox/Prompts/index.ts b/frontend/src/components/TradeBox/Prompts/index.ts index 042e08e12..fe514084f 100644 --- a/frontend/src/components/TradeBox/Prompts/index.ts +++ b/frontend/src/components/TradeBox/Prompts/index.ts @@ -1,2 +1,17 @@ export { LockInvoicePrompt } from './LockInvoice'; export { TakerFoundPrompt } from './TakerFound'; +export { PublicWaitPrompt } from './PublicWait'; +export { PausedPrompt } from './Paused'; +export { ExpiredPrompt } from './Expired'; +export { PayoutPrompt } from './Payout'; +export { EscrowWaitPrompt } from './EscrowWait'; +export { PayoutWaitPrompt } from './PayoutWait'; +export { ChatPrompt } from './Chat'; +export { DisputePrompt } from './Dispute'; +export { DisputeWaitPeerPrompt } from './DisputeWaitPeer'; +export { DisputeWaitResolutionPrompt } from './DisputeWaitResolution'; +export { SendingSatsPrompt } from './SendingSats'; +export { SuccessfulPrompt } from './Successful'; +export { RoutingFailedPrompt } from './RoutingFailed'; +export { DisputeWinnerPrompt } from './DisputeWinner'; +export { DisputeLoserPrompt } from './DisputeLoser'; diff --git a/frontend/src/components/TradeBox/Title/index.tsx b/frontend/src/components/TradeBox/Title/index.tsx index 402bf64d7..77c0aefc2 100644 --- a/frontend/src/components/TradeBox/Title/index.tsx +++ b/frontend/src/components/TradeBox/Title/index.tsx @@ -3,92 +3,46 @@ import { useTranslation } from 'react-i18next'; import { Typography, useTheme } from '@mui/material'; import { Order } from '../../../models'; import stepXofY from '../stepXofY'; -import currencies from '../../../../static/assets/currencies.json'; -import { pn } from '../../../utils'; interface TakerFoundPrompProps { order: Order; + text: string; + variables?: Object; + color?: string; + icon?: () => JSX.Element; } -export const Title = ({ order }: TakerFoundPrompProps): JSX.Element => { +export const Title = ({ + order, + text, + variables = {}, + color = 'primary', + icon = function () { + return <>; + }, +}: TakerFoundPrompProps): JSX.Element => { const { t } = useTranslation(); const theme = useTheme(); - const currencyCode: string = currencies[`${order.currency}`]; - let text = ''; - - if (order.is_maker && order.status === 0) { - text = t('Lock {{amountSats}} Sats to PUBLISH order', { amountSats: pn(order.bond_satoshis) }); - } else if (order.is_taker && order.status === 3) { - text = t('Lock {{amountSats}} Sats to TAKE order', { amountSats: pn(order.bond_satoshis) }); - } else if (order.is_seller && [6, 7].includes(order.status)) { - text = t('Lock {{amountSats}} Sats as collateral', { amountSats: pn(order.escrow_satoshis) }); - } - - { - /* Maker and taker Bond request */ + let textColor = color; + if (color == 'warning') { + textColor = theme.palette.warning.main; + } else if (color == 'success') { + textColor = theme.palette.success.main; } - // {this.props.data.is_maker & (this.props.data.status == 0) ? this.showQRInvoice() : ''} - // {this.props.data.is_taker & (this.props.data.status == 3) ? this.showQRInvoice() : ''} - - // {/* Waiting for taker and taker bond request */} - // {this.props.data.is_maker & (this.props.data.status == 2) ? this.showPausedOrder() : ''} - // {this.props.data.is_maker & (this.props.data.status == 1) ? this.showMakerWait() : ''} - // {this.props.data.is_maker & (this.props.data.status == 3) ? this.showTakerFound() : ''} - - // {/* Send Invoice (buyer) and deposit collateral (seller) */} - // {this.props.data.is_seller & - // (this.props.data.status == 6 || this.props.data.status == 7) - // ? this.showEscrowQRInvoice() - // : ''} - // {this.props.data.is_buyer & (this.props.data.status == 6 || this.props.data.status == 8) - // ? this.showInputInvoice() - // : ''} - // {this.props.data.is_buyer & (this.props.data.status == 7) - // ? this.showWaitingForEscrow() - // : ''} - // {this.props.data.is_seller & (this.props.data.status == 8) - // ? this.showWaitingForBuyerInvoice() - // : ''} - - // {/* In Chatroom */} - // {this.props.data.status == 9 || this.props.data.status == 10 ? this.showChat() : ''} - - // {/* Trade Finished */} - // {this.props.data.is_seller & [13, 14, 15].includes(this.props.data.status) - // ? this.showRateSelect() - // : ''} - // {this.props.data.is_buyer & (this.props.data.status == 14) ? this.showRateSelect() : ''} - - // {/* Trade Finished - Payment Routing Failed */} - // {this.props.data.is_buyer & (this.props.data.status == 13) - // ? this.showSendingPayment() - // : ''} - - // {/* Trade Finished - Payment Routing Failed */} - // {this.props.data.is_buyer & (this.props.data.status == 15) - // ? this.showRoutingFailed() - // : ''} - - // {/* Trade Finished - TODO Needs more planning */} - // {this.props.data.status == 11 ? this.showInDisputeStatement() : ''} - // {this.props.data.status == 16 ? this.showWaitForDisputeResolution() : ''} - // {(this.props.data.status == 17) & this.props.data.is_taker || - // (this.props.data.status == 18) & this.props.data.is_maker - // ? this.showDisputeWinner() - // : ''} - // {(this.props.data.status == 18) & this.props.data.is_taker || - // (this.props.data.status == 17) & this.props.data.is_maker - // ? this.showDisputeLoser() - // : ''} - - // {/* Order has expired */} - // {this.props.data.status == 5 ? this.showOrderExpired() : ''} return ( - - {text} - {stepXofY(order)} + + {icon()} + + {t(text, variables)} {stepXofY(order)} + + {icon()} ); }; diff --git a/frontend/src/components/TradeBox/TradeSummary.tsx b/frontend/src/components/TradeBox/TradeSummary.tsx index eb3ea4e57..3f52e8bdb 100644 --- a/frontend/src/components/TradeBox/TradeSummary.tsx +++ b/frontend/src/components/TradeBox/TradeSummary.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import { format } from 'date-fns'; import { useTranslation } from 'react-i18next'; import { - Avatar, Badge, ToggleButton, ToggleButtonGroup, @@ -10,43 +9,39 @@ import { ListItem, ListItemText, ListItemIcon, - Grid, Tooltip, IconButton, - Accordion, - AccordionSummary, - AccordionDetails, + Box, Typography, + useTheme, } from '@mui/material'; import { pn, saveAsJson } from '../../utils'; import RobotAvatar from '../RobotAvatar'; // Icons -import { FlagWithProps } from '../Icons'; -import ScheduleIcon from '@mui/icons-material/Schedule'; -import PriceChangeIcon from '@mui/icons-material/PriceChange'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import DownloadIcon from '@mui/icons-material/Download'; -import AccountBalanceIcon from '@mui/icons-material/AccountBalance'; -import RouteIcon from '@mui/icons-material/Route'; -import AccountBoxIcon from '@mui/icons-material/AccountBox'; -import LockOpenIcon from '@mui/icons-material/LockOpen'; -import LinkIcon from '@mui/icons-material/Link'; +import { ExportIcon, FlagWithProps } from '../Icons'; +import { + Schedule, + PriceChange, + LockOpen, + Download, + AccountBalance, + Route, + AccountBox, + Link, +} from '@mui/icons-material'; import { RoboSatsNoTextIcon, SendReceiveIcon, BitcoinIcon } from '../Icons'; - -interface Item { - id: string; - name: string; -} +import { TradeCoordinatorSummary, TradeRobotSummary } from '../../models/Order.model'; +import { systemClient } from '../../services/System'; interface Props { isMaker: boolean; makerNick: string; takerNick: string; currencyCode: string; - makerSummary: Record; - takerSummary: Record; - platformSummary: Record; + makerSummary: TradeRobotSummary; + takerSummary: TradeRobotSummary; + platformSummary: TradeCoordinatorSummary; orderId: number; baseUrl: string; } @@ -63,258 +58,257 @@ const TradeSummary = ({ baseUrl, }: Props): JSX.Element => { const { t, i18n } = useTranslation(); + const theme = useTheme(); + const [buttonValue, setButtonValue] = useState(isMaker ? 0 : 2); const userSummary = buttonValue == 0 ? makerSummary : takerSummary; - const contractTimestamp = new Date(platformSummary.contract_timestamp); + const contractTimestamp = new Date(platformSummary.contract_timestamp ?? null); const total_time = platformSummary.contract_total_time; const hours = parseInt(total_time / 3600); const mins = parseInt((total_time - hours * 3600) / 60); const secs = parseInt(total_time - hours * 3600 - mins * 60); + const onClickExport = function () { + const summary = { + order_id: orderId, + currency: currencyCode, + maker: makerSummary, + taker: takerSummary, + platform: platformSummary, + }; + if (window.NativeRobosats === undefined) { + saveAsJson(`order${orderId}-summary.json`, summary); + } else { + systemClient.copyToClipboard(JSON.stringify(summary)); + } + }; + return ( - - +
+ + {t('Trade Summary')} + + + + + + +
+
- }> - - {t('Trade Summary')} - - - -
- - setButtonValue(0)}> - -   - {t('Maker')} - - setButtonValue(1)}> - - - setButtonValue(2)}> - {t('Taker')} -   - - - - - - - saveAsJson(`order${orderId}-summary.json`, { - order_id: orderId, - currency: currencyCode, - maker: makerSummary, - taker: takerSummary, - platform: platformSummary, - }) - } - > - - - - -
- {/* Maker/Taker Summary */} -
- - - - - {userSummary.is_buyer ? ( - - ) : ( - - )} -
- } - > - - - - - - -
- + + setButtonValue(0)}> + +   + {t('Maker')} + + setButtonValue(1)}> + + + setButtonValue(2)}> + {t('Taker')} +   + + + +
+ {/* Maker/Taker Summary */} +
+ + + + + {userSummary.is_buyer ? ( + + ) : ( + + )}
-
- + - + + + - - - - - + +
+ +
+
+ +
- - + + + + + - {userSummary.is_swap ? ( - - - - - - - - ) : null} + + - - - - - - {t('Unlocked')}} /> - - -
- {/* Platform Summary */} -
- - - - - - - + {userSummary.is_swap ? ( + + + + + + + + ) : null} - - - - - - + + + + + + {t('Unlocked')}} + /> + + +
+ {/* Platform Summary */} +
+ + + + + + + - - - - - - + + + + + + - - - - - - - - -
- -
-
+ + + + + + + + + + + + + + + +
+ ); }; diff --git a/frontend/src/components/TradeBox/WalletsButton.tsx b/frontend/src/components/TradeBox/WalletsButton.tsx new file mode 100644 index 000000000..97a94045d --- /dev/null +++ b/frontend/src/components/TradeBox/WalletsButton.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Link } from '@mui/material'; + +import { AccountBalanceWallet } from '@mui/icons-material'; +import { NewTabIcon } from '../Icons'; + +const WalletsButton = (): JSX.Element => { + const { t } = useTranslation(); + return ( + + ); +}; + +export default WalletsButton; diff --git a/frontend/src/components/TradeBox/index.js b/frontend/src/components/TradeBox/index.js deleted file mode 100644 index 9d7b25673..000000000 --- a/frontend/src/components/TradeBox/index.js +++ /dev/null @@ -1,1933 +0,0 @@ -import React, { Component } from 'react'; -import { withTranslation, Trans } from 'react-i18next'; -import { - Alert, - AlertTitle, - ToggleButtonGroup, - ToggleButton, - IconButton, - Link, - Paper, - Rating, - Button, - Tooltip, - CircularProgress, - Grid, - Typography, - TextField, - List, - ListItem, - ListItemText, - Divider, - Switch, - ListItemIcon, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from '@mui/material'; -import { LoadingButton } from '@mui/lab'; -import QRCode from 'react-qr-code'; -import Countdown, { zeroPad } from 'react-countdown'; -import EncryptedChat from './EncryptedChat'; -import TradeSummary from './TradeSummary'; -import MediaQuery from 'react-responsive'; -import { systemClient } from '../../services/System'; -import { apiClient } from '../../services/api'; - -// Icons -import PercentIcon from '@mui/icons-material/Percent'; -import BookIcon from '@mui/icons-material/Book'; -import LockIcon from '@mui/icons-material/Lock'; -import LockOpenIcon from '@mui/icons-material/LockOpen'; -import BalanceIcon from '@mui/icons-material/Balance'; -import ContentCopy from '@mui/icons-material/ContentCopy'; -import PauseCircleIcon from '@mui/icons-material/PauseCircle'; -import PlayCircleIcon from '@mui/icons-material/PlayCircle'; -import BoltIcon from '@mui/icons-material/Bolt'; -import LinkIcon from '@mui/icons-material/Link'; -import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; -import WifiTetheringErrorIcon from '@mui/icons-material/WifiTetheringError'; -import FavoriteIcon from '@mui/icons-material/Favorite'; -import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; -import RefreshIcon from '@mui/icons-material/Refresh'; -import { NewTabIcon } from '../Icons'; - -import { pn } from '../../utils/prettyNumbers'; - -class TradeBox extends Component { - invoice_escrow_duration = 3; - - constructor(props) { - super(props); - this.state = { - openConfirmFiatReceived: false, - loadingButtonFiatSent: false, - loadingButtonFiatReceived: false, - loadingSubmitInvoice: false, - loadingSubmitAddress: false, - openConfirmDispute: false, - receiveTab: 0, - address: '', - miningFee: 1.05, - badInvoice: false, - badAddress: false, - badStatement: false, - turtleMode: window.ReactNativeWebView !== undefined, - }; - } - - Sound = (soundFileName) => ( - // Four filenames: "locked-invoice", "taker-found", "open-chat", "successful" -