Add Tradebox, OrderDetails, Notifications, OrderPage functional components. (#315)

* Re-init new tradebox

* Wip progress on OrderDetails

* Wip 2 OrderDetails

* Fix multiple requests on load

* Add functional Order page

* Fixes order page

* Fix delete storage

* Fix order page style

* Add Public order prompt

* Add paused order prompt

* Add expired prompt

* Create statusToContract logics

* Move fetch order loop to Main

* Add payout and wait prompts

* Fix order fetch on badOrder

* Fix styles

* Add chat and dispute prompts

* Add remaining prompts

* Fix style

* Add notifications component

* Fix take order style, add more notifications

* Add page title notification

* Add more notifications and small tradebox fixes

* Small fixes

* Small fixes to routing failure prompt

* Remove old trade box

* Add bad take order
This commit is contained in:
Reckless_Satoshi 2022-11-21 12:56:29 +00:00 committed by GitHub
parent 25074351f3
commit 6b2dedce13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
69 changed files with 4449 additions and 5335 deletions

View File

@ -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);

View File

@ -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<Book>({ orders: [], loading: true });
@ -65,8 +91,10 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => {
const [baseUrl, setBaseUrl] = useState<string>('');
const [fav, setFav] = useState<Favorites>({ type: null, currency: 0 });
const theme = useTheme();
const history = useHistory();
const [delay, setDelay] = useState<number>(60000);
const [timer, setTimer] = useState<NodeJS.Timer | undefined>(setInterval(() => null, delay));
const [order, setOrder] = useState<Order | undefined>(undefined);
const [badOrder, setBadOrder] = useState<string | undefined>(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<number | null>(null);
const [currentOrder, setCurrentOrder] = useState<number | undefined>(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 (
<Router basename={basename}>
{/* 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 })}
/>
<Notifications
order={order}
page={page}
openProfile={() => setOpen({ ...closeAll, profile: true })}
rewards={robot.earnedRewards}
setPage={setPage}
windowWidth={windowSize.width}
/>
{settings.network === 'testnet' ? (
<div style={{ height: 0 }}>
<Typography color='secondary' align='center'>
@ -286,12 +364,17 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => {
<BookPage
book={book}
fetchBook={fetchBook}
onViewOrder={() => {
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 => {
>
<div>
<OrderPage
theme={theme}
history={history}
{...props}
setPage={setPage}
baseUrl={baseUrl}
order={order}
setOrder={setOrder}
setCurrentOrder={setCurrentOrder}
badOrder={badOrder}
locationOrderId={props.match.params.orderId}
setBadOrder={setBadOrder}
hasRobot={robot.avatarLoaded}
windowSize={{ ...windowSize, height: windowSize.height - navbarHeight }}
setPage={setPage}
/>
</div>
</Slide>
@ -366,21 +455,23 @@ const Main = ({ settings, setSettings }: MainProps): JSX.Element => {
</Route>
</Switch>
</Box>
<NavBar
nickname={robot.avatarLoaded ? robot.nickname : null}
color={settings.network === 'mainnet' ? 'primary' : 'secondary'}
width={windowSize.width}
height={navbarHeight}
page={page}
setPage={setPage}
open={open}
setOpen={setOpen}
closeAll={closeAll}
setSlideDirection={setSlideDirection}
currentOrder={currentOrder}
hasRobot={robot.avatarLoaded}
baseUrl={baseUrl}
/>
<div style={{ alignContent: 'center', display: 'flex' }}>
<NavBar
nickname={robot.avatarLoaded ? robot.nickname : null}
color={settings.network === 'mainnet' ? 'primary' : 'secondary'}
width={windowSize.width}
height={navbarHeight}
page={page}
setPage={setPage}
open={open}
setOpen={setOpen}
closeAll={closeAll}
setSlideDirection={setSlideDirection}
currentOrder={currentOrder}
hasRobot={robot.avatarLoaded}
baseUrl={baseUrl}
/>
</div>
<MainDialogs
open={open}
setOpen={setOpen}

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { Grid, Paper, Collapse, Typography } from '@mui/material';
import { LimitList, Maker, Book, Favorites } from '../../models';
import { LimitList, Maker, Book, Favorites, Order } from '../../models';
import { filterOrders } from '../../utils';
@ -20,6 +20,7 @@ interface MakerPageProps {
maker: Maker;
setFav: (state: Favorites) => 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}
/>
</Grid>
</Grid>
@ -99,6 +102,7 @@ const MakerPage = ({
maker={maker}
setMaker={setMaker}
onOrderCreated={(id) => {
clearOrder();
setCurrentOrder(id);
setPage('order');
history.push('/order/' + id);

View File

@ -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={<Assignment />}
iconPosition='start'
/>

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<Box>
{order == undefined && badOrder == undefined ? <CircularProgress /> : null}
{badOrder != undefined ? (
<Typography align='center' variant='subtitle2' color='secondary'>
{t(badOrder)}
</Typography>
) : null}
{order != undefined && badOrder == undefined ? (
order.is_participant ? (
windowSize.width > doublePageWidth ? (
// DOUBLE PAPER VIEW
<Grid
container
direction='row'
justifyContent='center'
alignItems='flex-start'
spacing={2}
style={{ width: '43em' }}
>
<Grid item xs={6} style={{ width: '21em' }}>
<Paper
elevation={12}
style={{
width: '21em',
maxHeight: `${maxHeight}em`,
overflow: 'auto',
}}
>
<OrderDetails
order={order}
setOrder={setOrder}
baseUrl={baseUrl}
setPage={setPage}
hasRobot={hasRobot}
/>
</Paper>
</Grid>
<Grid item xs={6} style={{ width: '21em' }}>
<Paper
elevation={12}
style={{
width: '21em',
maxHeight: `${maxHeight}em`,
overflow: 'auto',
}}
>
<TradeBox
order={order}
setOrder={setOrder}
setBadOrder={setBadOrder}
baseUrl={baseUrl}
onRenewOrder={renewOrder}
onStartAgain={startAgain}
/>
</Paper>
</Grid>
</Grid>
) : (
// SINGLE PAPER VIEW
<Box>
<Box sx={{ borderBottom: 1, borderColor: 'divider', width: '21em' }}>
<Tabs
value={tab}
onChange={(mouseEvent, value) => setTab(value)}
variant='fullWidth'
>
<Tab label={t('Order')} value='order' />
<Tab label={t('Contract')} value='contract' />
</Tabs>
</Box>
<Paper
elevation={12}
style={{
width: '21em',
maxHeight: `${maxHeight}em`,
overflow: 'auto',
}}
>
<div style={{ display: tab == 'order' ? '' : 'none' }}>
<OrderDetails
order={order}
setOrder={setOrder}
baseUrl={baseUrl}
setPage={setPage}
hasRobot={hasRobot}
/>
</div>
<div style={{ display: tab == 'contract' ? '' : 'none' }}>
<TradeBox
order={order}
setOrder={setOrder}
setBadOrder={setBadOrder}
baseUrl={baseUrl}
onRenewOrder={renewOrder}
onStartAgain={startAgain}
/>
</div>
</Paper>
</Box>
)
) : (
<Paper
elevation={12}
style={{
width: '21em',
maxHeight: `${maxHeight}em`,
overflow: 'auto',
}}
>
<OrderDetails
order={order}
setOrder={setOrder}
baseUrl={baseUrl}
setPage={setPage}
hasRobot={hasRobot}
/>
</Paper>
)
) : (
<></>
)}
</Box>
);
};
export default OrderPage;

View File

@ -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 = () => {

View File

@ -68,7 +68,6 @@ const ProfileDialog = ({
const host = getHost();
const [rewardInvoice, setRewardInvoice] = useState<string>('');
const [showRewards, setShowRewards] = useState<boolean>(false);
const [showRewardsSpinner, setShowRewardsSpinner] = useState<boolean>(false);
const [withdrawn, setWithdrawn] = useState<boolean>(false);
const [badInvoice, setBadInvoice] = useState<string>('');
@ -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 = ({
<ListItem>
<ListItemIcon>
<BitcoinIcon />
<PersonAddAltIcon />
</ListItemIcon>
<ListItemText>
<FormControlLabel
labelPlacement='end'
label={
<div style={{ display: 'flex', alignItems: 'center' }}>
{t('Rewards and compensations')}
</div>
}
control={
<Switch checked={showRewards} onChange={() => setShowRewards(!showRewards)} />
}
<ListItemText secondary={t('Share to earn 100 Sats per trade')}>
<TextField
label={t('Your referral link')}
value={host + '/robot/' + robot.referralCode}
size='small'
InputProps={{
endAdornment: (
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!') || ''}>
<IconButton onClick={copyReferralCodeHandler}>
<ContentCopy />
</IconButton>
</Tooltip>
),
}}
/>
</ListItemText>
</ListItem>
{showRewards && (
<>
<ListItem>
<ListItemIcon>
<PersonAddAltIcon />
</ListItemIcon>
<ListItem>
<ListItemIcon>
<EmojiEventsIcon />
</ListItemIcon>
<ListItemText secondary={t('Share to earn 100 Sats per trade')}>
<TextField
label={t('Your referral link')}
value={host + '/robot/' + robot.referralCode}
size='small'
InputProps={{
endAdornment: (
<Tooltip
disableHoverListener
enterTouchDelay={0}
title={t('Copied!') || ''}
>
<IconButton onClick={copyReferralCodeHandler}>
<ContentCopy />
</IconButton>
</Tooltip>
),
}}
/>
</ListItemText>
</ListItem>
{!openClaimRewards ? (
<ListItemText secondary={t('Your earned rewards')}>
<Grid container>
<Grid item xs={9}>
<Typography>{`${robot.earnedRewards} Sats`}</Typography>
</Grid>
<ListItem>
<ListItemIcon>
<EmojiEventsIcon />
</ListItemIcon>
{!openClaimRewards ? (
<ListItemText secondary={t('Your earned rewards')}>
<Grid container>
<Grid item xs={9}>
<Typography>{`${robot.earnedRewards} Sats`}</Typography>
</Grid>
<Grid item xs={3}>
<Button
disabled={robot.earnedRewards === 0}
onClick={() => setOpenClaimRewards(true)}
variant='contained'
size='small'
>
{t('Claim')}
</Button>
</Grid>
<Grid item xs={3}>
<Button
disabled={robot.earnedRewards === 0}
onClick={() => setOpenClaimRewards(true)}
variant='contained'
size='small'
>
{t('Claim')}
</Button>
</Grid>
</Grid>
</ListItemText>
) : (
<form noValidate style={{ maxWidth: 270 }}>
<Grid container style={{ display: 'flex', alignItems: 'stretch' }}>
<Grid item style={{ display: 'flex', maxWidth: 160 }}>
<TextField
error={!!badInvoice}
helperText={badInvoice || ''}
label={t('Invoice for {{amountSats}} Sats', {
amountSats: robot.earnedRewards,
})}
size='small'
value={rewardInvoice}
onChange={(e) => {
setRewardInvoice(e.target.value);
}}
/>
</Grid>
<Grid item alignItems='stretch' style={{ display: 'flex', maxWidth: 80 }}>
<Button
sx={{ maxHeight: 38 }}
onClick={(e) => handleSubmitInvoiceClicked(e, rewardInvoice)}
variant='contained'
color='primary'
size='small'
type='submit'
>
{t('Submit')}
</Button>
</Grid>
</Grid>
{weblnEnabled && (
<Grid container style={{ display: 'flex', alignItems: 'stretch' }}>
<Grid item alignItems='stretch' style={{ display: 'flex', maxWidth: 240 }}>
<Button
sx={{ maxHeight: 38, minWidth: 230 }}
onClick={async (e) => await handleWeblnInvoiceClicked(e)}
variant='contained'
color='primary'
size='small'
type='submit'
>
{t('Generate with Webln')}
</Button>
</Grid>
</ListItemText>
) : (
<form noValidate style={{ maxWidth: 270 }}>
<Grid container style={{ display: 'flex', alignItems: 'stretch' }}>
<Grid item style={{ display: 'flex', maxWidth: 160 }}>
<TextField
error={!!badInvoice}
helperText={badInvoice || ''}
label={t('Invoice for {{amountSats}} Sats', {
amountSats: robot.earnedRewards,
})}
size='small'
value={rewardInvoice}
onChange={(e) => {
setRewardInvoice(e.target.value);
}}
/>
</Grid>
<Grid item alignItems='stretch' style={{ display: 'flex', maxWidth: 80 }}>
<Button
sx={{ maxHeight: 38 }}
onClick={(e) => handleSubmitInvoiceClicked(e, rewardInvoice)}
variant='contained'
color='primary'
size='small'
type='submit'
>
{t('Submit')}
</Button>
</Grid>
</Grid>
{weblnEnabled && (
<Grid container style={{ display: 'flex', alignItems: 'stretch' }}>
<Grid item alignItems='stretch' style={{ display: 'flex', maxWidth: 240 }}>
<Button
sx={{ maxHeight: 38, minWidth: 230 }}
onClick={async (e) => await handleWeblnInvoiceClicked(e)}
variant='contained'
color='primary'
size='small'
type='submit'
>
{t('Generate with Webln')}
</Button>
</Grid>
</Grid>
)}
</form>
</Grid>
)}
</ListItem>
</form>
)}
</ListItem>
{showRewardsSpinner && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</div>
)}
{showRewardsSpinner && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</div>
)}
{withdrawn && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Typography color='primary' variant='body2'>
<b>{t('There it goes, thank you!🥇')}</b>
</Typography>
</div>
)}
</>
{withdrawn && (
<div style={{ display: 'flex', justifyContent: 'center' }}>
<Typography color='primary' variant='body2'>
<b>{t('There it goes, thank you!🥇')}</b>
</Typography>
</div>
)}
</List>
</DialogContent>

View File

@ -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;

View File

@ -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) => (
<Tooltip {...props} classes={{ popper: className }} />
))(({ 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<NotificationMessage>(emptyNotificationMessage);
const [inFocus, setInFocus] = useState<boolean>(true);
const [titleAnimation, setTitleAnimation] = useState<NodeJS.Timer | undefined>(undefined);
const [show, setShow] = useState<boolean>(false);
// Keep last values to trigger effects on change
const [oldOrderStatus, setOldOrderStatus] = useState<number | undefined>(undefined);
const [oldRewards, setOldRewards] = useState<number>(0);
const [oldChatIndex, setOldChatIndex] = useState<number>(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 (
<StyledTooltip
open={show}
style={{ padding: 0, backgroundColor: 'black' }}
placement={windowWidth > 60 ? 'left' : 'bottom'}
title={
<Alert
severity={message.severity}
action={
<IconButton
color='inherit'
size='small'
onClick={() => {
setShow(false);
}}
>
<Close fontSize='inherit' />
</IconButton>
}
>
<div style={{ cursor: 'pointer' }} onClick={message.onClick}>
{message.title}
</div>
</Alert>
}
>
<div style={{ ...position, visibility: 'hidden', position: 'absolute' }} />
</StyledTooltip>
);
};
export default Notifications;

View File

@ -8,7 +8,7 @@ interface Props {
}
const LinearDeterminate = ({ expiresAt, totalSecsExp }: Props): JSX.Element => {
const [progress, setProgress] = useState<number>(0);
const [progress, setProgress] = useState<number>(100);
useEffect(() => {
const timer = setInterval(() => {
@ -25,7 +25,12 @@ const LinearDeterminate = ({ expiresAt, totalSecsExp }: Props): JSX.Element => {
return (
<Box sx={{ width: '100%' }}>
<LinearProgress variant='determinate' value={progress} />
<LinearProgress
sx={{ height: '0.4em' }}
variant='determinate'
value={progress}
color={progress < 20 ? 'secondary' : 'primary'}
/>
</Box>
);
};

View File

@ -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<string>('');
const [badRequest, setBadRequest] = useState<string>('');
const [loadingTake, setLoadingTake] = useState<boolean>(false);
const [open, setOpen] = useState<OpenDialogsProps>(closeAll);
const currencyCode: string = currencies[`${order.currency}`];
const InactiveMakerDialog = function () {
return (
<Dialog open={open.inactiveMaker} onClose={() => setOpen({ ...open, inactiveMaker: false })}>
<DialogTitle>{t('The maker is away')}</DialogTitle>
<DialogContent>
<DialogContentText>
{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.',
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(closeAll)} autoFocus>
{t('Go back')}
</Button>
<Button onClick={() => setOpen({ inactiveMaker: false, confirmation: true })}>
{t('Sounds fine')}
</Button>
</DialogActions>
</Dialog>
);
};
const countdownTakeOrderRenderer = function ({ seconds, completed }) {
if (isNaN(seconds) || completed) {
return takeOrderButton();
} else {
return (
<Tooltip enterTouchDelay={0} title={t('Wait until you can take an order')}>
<Grid container sx={{ width: '100%' }} padding={1} justifyContent='center'>
<LoadingButton loading={loadingTake} disabled={true} variant='outlined' color='primary'>
{t('Take Order')}
</LoadingButton>
</Grid>
</Tooltip>
);
}
};
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 (
<Box
sx={{
padding: '0.5em',
backgroundColor: 'background.paper',
border: '1px solid',
borderRadius: '4px',
borderColor: theme.palette.mode === 'dark' ? '#434343' : '#c4c4c4',
'&:hover': {
borderColor: theme.palette.mode === 'dark' ? '#ffffff' : '#2f2f2f',
},
}}
>
<Grid container direction='row' alignItems='flex-start' justifyContent='space-evenly'>
<Grid item>
<Tooltip
placement='top'
enterTouchDelay={500}
enterDelay={700}
enterNextDelay={2000}
title={t('Enter amount of fiat to exchange for bitcoin')}
>
<TextField
error={
(Number(takeAmount) < Number(order.min_amount) ||
Number(takeAmount) > 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}
/>
</Tooltip>
</Grid>
<Grid item>
<div
style={{
display: invalidTakeAmount() ? '' : 'none',
}}
>
<Tooltip
placement='top'
enterTouchDelay={0}
enterDelay={500}
enterNextDelay={1200}
title={t('You must specify an amount first')}
>
<div>
<LoadingButton
loading={loadingTake}
sx={{ height: '2.8em' }}
variant='outlined'
color='primary'
disabled={true}
>
{t('Take Order')}
</LoadingButton>
</div>
</Tooltip>
</div>
<div
style={{
display: invalidTakeAmount() ? 'none' : '',
}}
>
<LoadingButton
loading={loadingTake}
sx={{ height: '2.8em' }}
variant='outlined'
color='primary'
onClick={onTakeOrderClicked}
>
{t('Take Order')}
</LoadingButton>
</div>
</Grid>
</Grid>
</Box>
);
} else {
return (
<Box
style={{
display: 'flex',
justifyContent: 'center',
position: 'relative',
bottom: '0.25em',
}}
>
<LoadingButton
loading={loadingTake}
sx={{ height: '2.71em' }}
variant='outlined'
color='primary'
onClick={onTakeOrderClicked}
>
{t('Take Order')}
</LoadingButton>
</Box>
);
}
};
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 (
<Box>
<Countdown date={new Date(order.penalty)} renderer={countdownTakeOrderRenderer} />
{badRequest != '' ? (
<Box style={{ padding: '0.5em' }}>
<Typography align='center' color='secondary'>
{t(badRequest)}
</Typography>
</Box>
) : (
<></>
)}
<ConfirmationDialog
open={open.confirmation}
onClose={() => setOpen({ ...open, confirmation: false })}
setPage={setPage}
onClickDone={() => {
takeOrder();
setLoadingTake(true);
setOpen(closeAll);
}}
hasRobot={hasRobot}
/>
<InactiveMakerDialog />
</Box>
);
};
export default TakeButton;

View File

@ -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 <span> {t('The order has expired')}</span>;
} 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 ? (
<b>
<span style={{ color: col }}>
{`${hours}h ${zeroPad(minutes)}m ${zeroPad(seconds)}s `}
</span>
</b>
) : (
<span style={{ color: col }}>{`${hours}h ${zeroPad(minutes)}m ${zeroPad(seconds)}s `}</span>
);
}
};
const timerRenderer = function (seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds - hours * 3600) / 60);
return (
<span>
{hours > 0 ? hours + 'h' : ''} {minutes > 0 ? zeroPad(minutes) + 'm' : ''}{' '}
</span>
);
};
// Countdown Renderer callback with condition
const countdownPenaltyRenderer = function ({ minutes, seconds, completed }) {
if (completed) {
// Render a completed state
return <span> {t('Penalty lifted, good to go!')}</span>;
} else {
return (
<span>
{' '}
{t('You cannot take an order yet! Wait {{timeMin}}m {{timeSec}}s', {
timeMin: zeroPad(minutes),
timeSec: zeroPad(seconds),
})}{' '}
</span>
);
}
};
return (
<Grid container spacing={0}>
<Grid item xs={12}>
<List dense={true}>
<ListItem>
<ListItemAvatar sx={{ width: '4em', height: '4em' }}>
<RobotAvatar
statusColor={statusBadgeColor(order.maker_status)}
nickname={order.maker_nick}
tooltip={t(order.maker_status)}
orderType={order.type}
baseUrl={baseUrl}
/>
</ListItemAvatar>
<ListItemText
primary={order.maker_nick + (order.type ? ' ' + t('(Seller)') : ' ' + t('(Buyer)'))}
secondary={t('Order maker')}
/>
</ListItem>
<Collapse in={order.is_participant && order.taker_nick !== 'None'}>
<Divider />
<ListItem>
<ListItemText
primary={`${order.taker_nick} ${order.type ? t('(Buyer)') : t('(Seller)')}`}
secondary={t('Order taker')}
/>
<ListItemAvatar>
<RobotAvatar
avatarClass='smallAvatar'
statusColor={statusBadgeColor(order.taker_status)}
nickname={order.taker_nick == 'None' ? undefined : order.taker_nick}
tooltip={t(order.taker_status)}
orderType={order.type === 0 ? 1 : 0}
baseUrl={baseUrl}
/>
</ListItemAvatar>
</ListItem>
</Collapse>
<Divider>
<Chip label={t('Order Details')} />
</Divider>
<Collapse in={order.is_participant}>
<ListItem>
<ListItemIcon>
<Article />
</ListItemIcon>
<ListItemText primary={t(order.status_message)} secondary={t('Order status')} />
</ListItem>
<Divider />
</Collapse>
<ListItem>
<ListItemIcon>
<div
style={{
zoom: 1.25,
opacity: 0.7,
msZoom: 1.25,
WebkitZoom: 1.25,
MozTransform: 'scale(1.25,1.25)',
MozTransformOrigin: 'left center',
}}
>
<FlagWithProps code={currencyCode} width='1.2em' height='1.2em' />
</div>
</ListItemIcon>
<ListItemText primary={AmountString().primary} secondary={AmountString().secondary} />
</ListItem>
<Divider />
<ListItem>
<ListItemIcon>
<Payments />
</ListItemIcon>
<ListItemText
primary={
<PaymentStringAsIcons
size={1.42 * theme.typography.fontSize}
othersText={t('Others')}
verbose={true}
text={order.payment_method}
/>
}
secondary={
order.currency == 1000 ? t('Swap destination') : t('Accepted payment methods')
}
/>
</ListItem>
<Divider />
{/* If there is live Price and Premium data, show it. Otherwise show the order maker settings */}
<ListItem>
<ListItemIcon>
<PriceChange />
</ListItemIcon>
{order.price_now !== undefined ? (
<ListItemText
primary={t('{{price}} {{currencyCode}}/BTC - Premium: {{premium}}%', {
price: pn(order.price_now),
currencyCode,
premium: order.premium_now,
})}
secondary={t('Price and Premium')}
/>
) : null}
{!order.price_now && order.is_explicit ? (
<ListItemText primary={pn(order.satoshis)} secondary={t('Amount of Satoshis')} />
) : null}
{!order.price_now && !order.is_explicit ? (
<ListItemText
primary={parseFloat(Number(order.premium).toFixed(2)) + '%'}
secondary={t('Premium over market price')}
/>
) : null}
</ListItem>
<Divider />
<ListItem>
<ListItemIcon>
<Numbers />
</ListItemIcon>
<Grid container>
<Grid item xs={4.5}>
<ListItemText primary={order.id} secondary={t('Order ID')} />
</Grid>
<Grid item xs={7.5}>
<Grid container>
<Grid item xs={2}>
<ListItemIcon sx={{ position: 'relative', top: '12px', left: '-5px' }}>
<HourglassTop />
</ListItemIcon>
</Grid>
<Grid item xs={10}>
<ListItemText
primary={timerRenderer(order.escrow_duration)}
secondary={t('Deposit timer')}
></ListItemText>
</Grid>
</Grid>
</Grid>
</Grid>
</ListItem>
{/* if order is in a status that does not expire, do not show countdown */}
<Collapse in={![4, 5, 12, 13, 14, 15, 16, 17, 18].includes(order.status)}>
<Divider />
<ListItem>
<ListItemIcon>
<AccessTime />
</ListItemIcon>
<ListItemText secondary={t('Expires in')}>
<Countdown date={new Date(order.expires_at)} renderer={countdownRenderer} />
</ListItemText>
</ListItem>
<LinearDeterminate totalSecsExp={order.total_secs_exp} expiresAt={order.expires_at} />
</Collapse>
</List>
{/* If the user has a penalty/limit */}
{order.penalty !== undefined ? (
<Grid item xs={12}>
<Alert severity='warning' sx={{ borderRadius: '0' }}>
<Countdown date={new Date(order.penalty)} renderer={countdownPenaltyRenderer} />
</Alert>
</Grid>
) : (
<></>
)}
{!order.is_participant ? (
<Grid item xs={12}>
<TakeButton
order={order}
setOrder={setOrder}
baseUrl={baseUrl}
setPage={setPage}
hasRobot={hasRobot}
/>
</Grid>
) : (
<></>
)}
</Grid>
</Grid>
);
};
export default OrderDetails;

View File

@ -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<Props> = ({
const [avatarSrc, setAvatarSrc] = useState<string>();
useEffect(() => {
if (nickname != null) {
if (nickname != undefined) {
if (window.NativeRobosats === undefined) {
setAvatarSrc(baseUrl + '/static/assets/avatars/' + nickname + '.png');
} else {

View File

@ -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 },
];

View File

@ -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 (
<Box>
<Typography color='primary' variant='subtitle1' align='center'>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
<Icon />
{t(`Your ${isMaker ? 'maker' : 'taker'} bond is ${status}`)}
</div>
</Typography>
</Box>
<Typography color={color} variant='subtitle1' align='center'>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
<Icon sx={{ height: '0.9em', width: '0.9em' }} />
{t(`Your ${isMaker ? 'maker' : 'taker'} bond is ${status}`)}
</div>
</Typography>
);
}
};

View File

@ -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 (
<Box>
{showCancelButton ? (
<Tooltip
placement='top'
enterTouchDelay={500}
enterDelay={700}
enterNextDelay={2000}
title={
noConfirmation
? t('Cancel order and unlock bond instantly')
: t('Unilateral cancelation (bond at risk!)')
}
>
<div>
<LoadingButton
size='small'
loading={loading}
variant='outlined'
color='secondary'
onClick={noConfirmation ? onClickCancel : openCancelDialog}
>
{t('Cancel')}
</LoadingButton>
</div>
</Tooltip>
) : (
<></>
)}
{showCollabCancelButton ? (
<LoadingButton
size='small'
loading={loading}
variant='outlined'
color='secondary'
onClick={openCollabCancelDialog}
>
{t('Collaborative Cancel')}
</LoadingButton>
) : (
<></>
)}
</Box>
);
};
export default CancelButton;

View File

@ -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 != '' ? (
<Alert severity='warning' style={{ width: '100%' }}>
{text}
</Alert>
) : (
<></>
);
};
export default CollabCancelAlert;

View File

@ -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 (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t('Cancel the order?')}</DialogTitle>
<DialogContent>
<DialogContentText>
{t('If the order is cancelled now you will lose your bond.')}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} autoFocus>
{t('Go back')}
</Button>
<Button onClick={onCancelClick}>{t('Confirm Cancel')}</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmCancelDialog;

View File

@ -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 (
<Dialog open={open} onClose={onClose}>
<DialogTitle id='cancel-dialog-title'>{t('Collaborative cancel the order?')}</DialogTitle>
<DialogContent>
<DialogContentText id='cancel-dialog-description'>
{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')}` : ''}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} autoFocus>
{t('Go back')}
</Button>
<LoadingButton loading={loading} onClick={onCollabCancelClick}>
{peerAskedCancel ? t('Accept Cancelation') : t('Ask for Cancel')}
</LoadingButton>
</DialogActions>
</Dialog>
);
};
export default ConfirmCollabCancelDialog;

View File

@ -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 (
<Dialog open={open} onClose={onClose}>
<DialogTitle>{t('WebLN')}</DialogTitle>
<DialogContent>
<DialogContentText>
{waitingWebln ? (
<>
<CircularProgress
size={1.1 * theme.typography.fontSize}
thickness={5}
style={{ marginRight: '0.8em' }}
/>
{isBuyer
? t('Invoice not received, please check your WebLN wallet.')
: t('Payment not received, please check your WebLN wallet.')}
</>
) : (
<>
<Check color='success' />
{t('You can close now your WebLN wallet popup.')}
</>
)}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose} autoFocus>
{t('Done')}
</Button>
</DialogActions>
</Dialog>
);
};
export default WebLNDialog;

View File

@ -1,2 +1,5 @@
export { ConfirmDisputeDialog } from './ConfirmDispute';
export { ConfirmFiatReceivedDialog } from './ConfirmFiatReceived';
export { ConfirmCancelDialog } from './ConfirmCancel';
export { ConfirmCollabCancelDialog } from './ConfirmCollabCancel';
export { WebLNDialog } from './WebLN';

View File

@ -18,8 +18,15 @@ const ChatBottom: React.FC<Props> = ({ orderId, setAudit, audit, createJsonFile
const theme = useTheme();
return (
<>
<Grid item xs={6}>
<Grid
container
sx={{ width: '18em' }}
direction='row'
justifyContent='space-evenly'
alignItems='center'
padding={0.3}
>
<Grid item>
<Tooltip
placement='bottom'
enterTouchDelay={0}
@ -28,13 +35,13 @@ const ChatBottom: React.FC<Props> = ({ orderId, setAudit, audit, createJsonFile
title={t('Verify your privacy')}
>
<Button size='small' color='primary' variant='outlined' onClick={() => setAudit(!audit)}>
<KeyIcon />
<KeyIcon sx={{ width: '0.8em', height: '0.8em' }} />
{t('Audit PGP')}{' '}
</Button>
</Tooltip>
</Grid>
<Grid item xs={6}>
<Grid item>
{window.ReactNativeWebView === undefined ? (
<Tooltip
placement='bottom'
@ -71,7 +78,7 @@ const ChatBottom: React.FC<Props> = ({ orderId, setAudit, audit, createJsonFile
</Tooltip>
)}
</Grid>
</>
</Grid>
);
};

View File

@ -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<Props> = ({ connected, peerConnected }) => {
const ChatHeader: React.FC<Props> = ({ 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 (
<Grid container spacing={0.5}>
<Grid item xs={0.3} />
<Grid item xs={5.5}>
<Paper elevation={1} sx={connected ? { backgroundColor: connectedColor } : {}}>
<Typography variant='caption' sx={{ color: connectedTextColor }}>
<Grid
container
direction='row'
justifyContent='space-between'
alignItems='flex-end'
padding={0}
>
<Grid item>
<Paper
style={{
width: '7.2em',
height: '1.8em',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
elevation={1}
sx={connected ? { backgroundColor: connectedColor } : {}}
>
<Typography align='center' variant='caption' sx={{ color: connectedTextColor }}>
{t('You') + ': '}
{connected ? t('connected') : t('disconnected')}
</Typography>
</Paper>
</Grid>
<Grid item xs={0.4} />
<Grid item xs={5.5}>
<Paper elevation={1} sx={peerConnected ? { backgroundColor: connectedColor } : {}}>
<Typography variant='caption' sx={{ color: connectedTextColor }}>
<Grid item>
{window.ReactNativeWebView === undefined ? (
<Grid item>
<Tooltip
enterTouchDelay={0}
placement='top'
title={t('Activate slow mode (use it when the connection is slow)')}
>
<IconButton
size='small'
color={turtleMode ? 'primary' : 'inherit'}
onClick={() => setTurtleMode(!turtleMode)}
>
<WifiTetheringError />
</IconButton>
</Tooltip>
</Grid>
) : (
<></>
)}
</Grid>
<Grid item>
<Paper
style={{
width: '7.2em',
height: '1.8em',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
elevation={1}
sx={peerConnected ? { backgroundColor: connectedColor } : {}}
>
<Typography align='center' variant='caption' sx={{ color: connectedTextColor }}>
{t('Peer') + ': '}
{peerConnected ? t('connected') : t('disconnected')}
</Typography>
</Paper>
</Grid>
<Grid item xs={0.3} />
</Grid>
);
};

View File

@ -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<Props> = ({
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<Props> = ({
}
}, [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<Props> = ({
encrypted_private_key: ownEncPrivKey,
passphrase: token,
},
messages: messages,
messages,
};
};
@ -111,7 +124,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
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<Props> = ({
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<Props> = ({
};
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<Props> = ({
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<Props> = ({
};
return (
<Container component='main'>
<ChatHeader connected={connected} peerConnected={peerConnected} />
<div style={{ position: 'relative', left: '-0.14em', margin: '0 auto', width: '17.7em' }}>
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0.5}
>
<AuditPGPDialog
open={audit}
onClose={() => 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)}
/>
<Grid item>
<ChatHeader
connected={connected}
peerConnected={peerConnected}
turtleMode={turtleMode}
setTurtleMode={setTurtleMode}
/>
<Paper
elevation={1}
style={{
height: '21.42em',
maxHeight: '21.42em',
width: '17.7em',
height: '18.42em',
maxHeight: '18.42em',
width: '100%',
overflow: 'auto',
backgroundColor: theme.palette.background.paper,
}}
@ -250,8 +285,8 @@ const EncryptedSocketChat: React.FC<Props> = ({
/>
</Paper>
<form noValidate onSubmit={onButtonClicked}>
<Grid alignItems='stretch' style={{ display: 'flex' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<Grid alignItems='stretch' style={{ display: 'flex', width: '100%' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }} xs={9}>
<TextField
label={t('Type a message')}
variant='standard'
@ -267,12 +302,12 @@ const EncryptedSocketChat: React.FC<Props> = ({
onChange={(e) => {
setValue(e.target.value);
}}
sx={{ width: '13.7em' }}
fullWidth={true}
/>
</Grid>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }} xs={3}>
<Button
sx={{ width: '4.68em' }}
fullWidth={true}
disabled={!connected || waitingEcho || !peerPubKey}
type='submit'
variant='contained'
@ -304,23 +339,8 @@ const EncryptedSocketChat: React.FC<Props> = ({
</Grid>
</Grid>
</form>
</div>
<div style={{ height: '0.3em' }} />
<Grid container spacing={0}>
<AuditPGPDialog
open={audit}
onClose={() => 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)}
/>
</Grid>
<Grid item>
<ChatBottom
orderId={orderId}
audit={audit}
@ -328,7 +348,7 @@ const EncryptedSocketChat: React.FC<Props> = ({
createJsonFile={createJsonFile}
/>
</Grid>
</Container>
</Grid>
);
};

View File

@ -23,6 +23,8 @@ interface Props {
messages: EncryptedChatMessage[];
setMessages: (messages: EncryptedChatMessage[]) => void;
baseUrl: string;
turtleMode: boolean;
setTurtleMode: (state: boolean) => void;
}
const EncryptedTurtleChat: React.FC<Props> = ({
@ -33,6 +35,8 @@ const EncryptedTurtleChat: React.FC<Props> = ({
messages,
setMessages,
baseUrl,
setTurtleMode,
turtleMode,
}: Props): JSX.Element => {
const { t } = useTranslation();
const theme = useTheme();
@ -94,7 +98,7 @@ const EncryptedTurtleChat: React.FC<Props> = ({
encrypted_private_key: ownEncPrivKey,
passphrase: token,
},
messages: messages,
messages,
};
};
@ -112,7 +116,7 @@ const EncryptedTurtleChat: React.FC<Props> = ({
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<Props> = ({
};
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<Props> = ({
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<Props> = ({
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<Props> = ({
};
return (
<Container component='main'>
<ChatHeader connected={true} peerConnected={peerConnected} />
<div style={{ position: 'relative', left: '-0.14em', margin: '0 auto', width: '17.7em' }}>
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0.5}
>
<AuditPGPDialog
open={audit}
onClose={() => 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)}
/>
<Grid item>
<ChatHeader
connected={true}
peerConnected={peerConnected}
turtleMode={turtleMode}
setTurtleMode={setTurtleMode}
/>
<Paper
elevation={1}
style={{
height: '21.42em',
maxHeight: '21.42em',
width: '17.7em',
height: '18.42em',
maxHeight: '18.42em',
width: '100%',
overflow: 'auto',
backgroundColor: theme.palette.background.paper,
}}
@ -251,8 +278,8 @@ const EncryptedTurtleChat: React.FC<Props> = ({
/>
</Paper>
<form noValidate onSubmit={onButtonClicked}>
<Grid alignItems='stretch' style={{ display: 'flex' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<Grid alignItems='stretch' style={{ display: 'flex', width: '100%' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }} xs={9}>
<TextField
label={t('Type a message')}
variant='standard'
@ -261,16 +288,16 @@ const EncryptedTurtleChat: React.FC<Props> = ({
onChange={(e) => {
setValue(e.target.value);
}}
sx={{ width: '13.7em' }}
fullWidth={true}
/>
</Grid>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }} xs={3}>
<Button
sx={{ width: '4.68em' }}
disabled={waitingEcho || !peerPubKey}
type='submit'
variant='contained'
color='primary'
fullWidth={true}
>
{waitingEcho ? (
<div
@ -298,22 +325,9 @@ const EncryptedTurtleChat: React.FC<Props> = ({
</Grid>
</Grid>
</form>
</div>
</Grid>
<div style={{ height: '0.3em' }} />
<Grid container spacing={0}>
<AuditPGPDialog
open={audit}
onClose={() => 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)}
/>
<Grid item>
<ChatBottom
orderId={orderId}
audit={audit}
@ -321,7 +335,7 @@ const EncryptedTurtleChat: React.FC<Props> = ({
createJsonFile={createJsonFile}
/>
</Grid>
</Container>
</Grid>
);
};

View File

@ -59,7 +59,7 @@ const MessageCard: React.FC<Props> = ({ message, isTaker, userConnected, baseUrl
flexWrap: 'wrap',
position: 'relative',
left: '-0.35em',
width: '17.14em',
width: '100%',
}}
>
<div
@ -129,9 +129,7 @@ const MessageCard: React.FC<Props> = ({ message, isTaker, userConnected, baseUrl
subheaderTypographyProps={{
sx: {
wordWrap: 'break-word',
width: '14.3em',
position: 'relative',
right: '1.5em',
width: '13em',
textAlign: 'left',
fontSize: showPGP ? theme.typography.fontSize * 0.78 : null,
},

View File

@ -3,13 +3,14 @@ import EncryptedSocketChat from './EncryptedSocketChat';
import EncryptedTurtleChat from './EncryptedTurtleChat';
interface Props {
turtleMode: boolean;
orderId: number;
takerNick: string;
makerNick: string;
userNick: string;
chatOffset: number;
baseUrl: string;
messages: EncryptedChatMessage[];
setMessages: (state: EncryptedChatMessage[]) => void;
}
export interface EncryptedChatMessage {
@ -29,14 +30,15 @@ export interface ServerMessage {
}
const EncryptedChat: React.FC<Props> = ({
turtleMode,
orderId,
takerNick,
userNick,
chatOffset,
baseUrl,
setMessages,
messages,
}: Props): JSX.Element => {
const [messages, setMessages] = useState<EncryptedChatMessage[]>([]);
const [turtleMode, setTurtleMode] = useState<boolean>(window.ReactNativeWebView !== undefined);
return turtleMode ? (
<EncryptedTurtleChat
@ -47,6 +49,8 @@ const EncryptedChat: React.FC<Props> = ({
userNick={userNick}
chatOffset={chatOffset}
baseUrl={baseUrl}
turtleMode={turtleMode}
setTurtleMode={setTurtleMode}
/>
) : (
<EncryptedSocketChat
@ -56,6 +60,8 @@ const EncryptedChat: React.FC<Props> = ({
takerNick={takerNick}
userNick={userNick}
baseUrl={baseUrl}
turtleMode={turtleMode}
setTurtleMode={setTurtleMode}
/>
);
};

View File

@ -0,0 +1,91 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, TextField, Checkbox, Tooltip, FormControlLabel } from '@mui/material';
import { Order } from '../../../models';
import { LoadingButton } from '@mui/lab';
import { EncryptedChatMessage } from '../EncryptedChat';
export interface DisputeForm {
statement: string;
attachLogs: boolean;
badStatement: string;
}
export const defaultDispute: DisputeForm = {
statement: '',
attachLogs: true,
badStatement: '',
};
interface DisputeStatementFormProps {
loading: boolean;
dispute: DisputeForm;
setDispute: (state: DisputeForm) => void;
onClickSubmit: () => void;
}
export const DisputeStatementForm = ({
loading,
onClickSubmit,
dispute,
setDispute,
}: DisputeStatementFormProps): JSX.Element => {
const { t } = useTranslation();
return (
<Grid
container
sx={{ width: '18em' }}
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0.5}
padding={1}
>
<Grid item>
<TextField
error={dispute.badStatement != ''}
helperText={dispute.badStatement}
label={t('Submit dispute statement')}
required
inputProps={{
style: { textAlign: 'center' },
}}
multiline
rows={4}
onChange={(e) => setDispute({ ...dispute, statement: e.target.value })}
/>
</Grid>
<Grid item>
<Tooltip
enterTouchDelay={0}
placement='top'
title={t(
'Attaching chat logs helps the dispute resolution process and adds transparency. However, it might compromise your privacy.',
)}
>
<FormControlLabel
control={
<Checkbox
checked={dispute.attachLogs}
onChange={() => setDispute({ ...dispute, attachLogs: !dispute.attachLogs })}
/>
}
label={t('Attach chat logs')}
/>
</Tooltip>
</Grid>
<Grid item>
<LoadingButton
onClick={onClickSubmit}
variant='contained'
color='primary'
loading={loading}
>
{t('Submit')}
</LoadingButton>
</Grid>
</Grid>
);
};
export default DisputeStatementForm;

View File

@ -0,0 +1,90 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, Typography, TextField } from '@mui/material';
import { Order } from '../../../models';
import WalletsButton from '../WalletsButton';
import { LoadingButton } from '@mui/lab';
import { pn } from '../../../utils';
export interface LightningForm {
invoice: string;
routingBudget: number;
badInvoice: string;
useLnproxy: boolean;
lnproxyServer: string;
lnproxyBudget: number;
badLnproxy: string;
}
export const defaultLightning: LightningForm = {
invoice: '',
routingBudget: 0,
badInvoice: '',
useLnproxy: false,
lnproxyServer: '',
lnproxyBudget: 0,
badLnproxy: '',
};
interface LightningPayoutFormProps {
order: Order;
loading: boolean;
lightning: LightningForm;
setLightning: (state: LightningForm) => void;
onClickSubmit: (invoice: string) => void;
}
export const LightningPayoutForm = ({
order,
loading,
onClickSubmit,
lightning,
setLightning,
}: LightningPayoutFormProps): JSX.Element => {
const { t } = useTranslation();
return (
<Grid container direction='column' justifyContent='flex-start' alignItems='center' spacing={1}>
<Grid item xs={12}>
<Typography variant='body2'>
{t('Submit a valid invoice for {{amountSats}} Satoshis.', {
amountSats: pn(order.invoice_amount),
})}
</Typography>
</Grid>
<Grid item xs={12}>
<WalletsButton />
</Grid>
<Grid item xs={12}>
<TextField
fullWidth={true}
error={lightning.badInvoice != ''}
helperText={lightning.badInvoice ? t(lightning.badInvoice) : ''}
label={t('Payout Lightning Invoice')}
required
value={lightning.invoice}
inputProps={{
style: { textAlign: 'center', maxHeight: '14.28em' },
}}
multiline
minRows={4}
maxRows={8}
onChange={(e) => setLightning({ ...lightning, invoice: e.target.value ?? '' })}
/>
</Grid>
<Grid item xs={12}>
<LoadingButton
loading={loading}
onClick={() => onClickSubmit(lightning.invoice)}
variant='outlined'
color='primary'
>
{t('Submit')}
</LoadingButton>
</Grid>
</Grid>
);
};
export default LightningPayoutForm;

View File

@ -0,0 +1,159 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, Typography, TextField, List, Divider, ListItemText, ListItem } from '@mui/material';
import { Order } from '../../../models';
import WalletsButton from '../WalletsButton';
import { LoadingButton } from '@mui/lab';
import { pn } from '../../../utils';
export interface OnchainForm {
address: string;
miningFee: number;
badAddress: string;
}
export const defaultOnchain: OnchainForm = {
address: '',
miningFee: 10,
badAddress: '',
};
interface OnchainPayoutFormProps {
order: Order;
loading: boolean;
onchain: OnchainForm;
setOnchain: (state: OnchainForm) => void;
onClickSubmit: () => void;
}
export const OnchainPayoutForm = ({
order,
loading,
onClickSubmit,
onchain,
setOnchain,
}: OnchainPayoutFormProps): JSX.Element => {
const { t } = useTranslation();
const invalidFee = onchain.miningFee < 1 || onchain.miningFee > 50;
const costPerVByte = 141;
useEffect(() => {
setOnchain({ ...onchain, miningFee: order.suggested_mining_fee_rate });
}, []);
return (
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0.5}
>
<List dense={true}>
<ListItem>
<Typography variant='body2'>
{t('RoboSats coordinator will do a swap and send the Sats to your onchain address.')}
</Typography>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary={
pn(Math.floor((order.invoice_amount * order.swap_fee_rate) / 100)) +
' Sats (' +
order.swap_fee_rate +
'%)'
}
secondary={t('Swap fee')}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary={
pn(Math.floor(Math.max(1, onchain.miningFee) * costPerVByte)) +
' Sats (' +
Math.max(1, onchain.miningFee) +
' Sats/vByte)'
}
secondary={t('Mining fee')}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary={
<b>
{pn(
Math.floor(
order.invoice_amount -
Math.max(1, onchain.miningFee) * costPerVByte -
(order.invoice_amount * order.swap_fee_rate) / 100,
),
) + ' Sats'}
</b>
}
secondary={t('Final amount you will receive')}
/>
</ListItem>
</List>
<Grid item>
<Grid container direction='row' justifyContent='center' alignItems='flex-start' spacing={0}>
<Grid item xs={7}>
<TextField
error={onchain.badAddress != ''}
helperText={onchain.badAddress ? t(onchain.badAddress) : ''}
label={t('Bitcoin Address')}
required
value={onchain.address}
fullWidth={true}
inputProps={{
style: { textAlign: 'center' },
}}
onChange={(e) => setOnchain({ ...onchain, address: e.target.value })}
/>
</Grid>
<Grid item xs={5}>
<TextField
error={invalidFee}
helperText={invalidFee ? t('Invalid') : ''}
label={t('Mining Fee')}
required
fullWidth={true}
value={onchain.miningFee}
type='number'
inputProps={{
max: 50,
min: 1,
style: { textAlign: 'center' },
}}
onChange={(e) => setOnchain({ ...onchain, miningFee: Number(e.target.value) })}
/>
</Grid>
</Grid>
</Grid>
<Grid item>
<LoadingButton
loading={loading}
onClick={onClickSubmit}
disabled={invalidFee}
variant='outlined'
color='primary'
>
{t('Submit')}
</LoadingButton>
</Grid>
</Grid>
);
};
export default OnchainPayoutForm;

View File

@ -0,0 +1,7 @@
export { LightningPayoutForm, defaultLightning } from './LightningPayout';
export { OnchainPayoutForm, defaultOnchain } from './OnchainPayout';
export { DisputeStatementForm, defaultDispute } from './Dispute';
export type { LightningForm } from './LightningPayout';
export type { OnchainForm } from './OnchainPayout';
export type { DisputeForm } from './Dispute';

View File

@ -0,0 +1,189 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, Typography, Tooltip, Collapse, IconButton } from '@mui/material';
import currencies from '../../../../static/assets/currencies.json';
import { Order } from '../../../models';
import { pn } from '../../../utils';
import EncryptedChat, { EncryptedChatMessage } from '../EncryptedChat';
import Countdown, { zeroPad } from 'react-countdown';
import { LoadingButton } from '@mui/lab';
interface ChatPromptProps {
order: Order;
onClickConfirmSent: () => void;
loadingSent: boolean;
onClickConfirmReceived: () => void;
loadingReceived: boolean;
onClickDispute: () => void;
loadingDispute: boolean;
baseUrl: string;
messages: EncryptedChatMessage[];
setMessages: (state: EncryptedChatMessage[]) => void;
}
export const ChatPrompt = ({
order,
onClickConfirmSent,
onClickConfirmReceived,
loadingSent,
loadingReceived,
onClickDispute,
loadingDispute,
baseUrl,
messages,
setMessages,
}: ChatPromptProps): JSX.Element => {
const { t } = useTranslation();
const [sentButton, setSentButton] = useState<boolean>(false);
const [receivedButton, setReceivedButton] = useState<boolean>(false);
const [enableDisputeButton, setEnableDisputeButton] = useState<boolean>(false);
const [enableDisputeTime, setEnableDisputeTime] = useState<Date>(new Date(order.expires_at));
const [text, setText] = useState<string>('');
const currencyCode: string = currencies[`${order.currency}`];
const amount: string = pn(
parseFloat(parseFloat(order.amount).toFixed(order.currency == 1000 ? 8 : 4)),
);
const disputeCountdownRenderer = function ({ hours, minutes }) {
return (
<span>{`${t('To open a dispute you need to wait')} ${hours}h ${zeroPad(minutes)}m `}</span>
);
};
useEffect(() => {
// open dispute button enables 12h before expiry
const now = Date.now();
const expires_at = new Date(order.expires_at);
expires_at.setHours(expires_at.getHours() - 12);
setEnableDisputeButton(now > expires_at);
setEnableDisputeTime(expires_at);
if (order.status == 9) {
// No fiat sent yet
if (order.is_buyer) {
setSentButton(true);
setReceivedButton(false);
setText(
t(
"Say hi! Ask for payment details and click 'Confirm Sent' as soon as the payment is sent.",
),
);
} else {
setSentButton(false);
setReceivedButton(false);
setText(
t(
'Say hi! Be helpful and concise. Let them know how to send you {{amount}} {{currencyCode}}.',
{
currencyCode,
amount,
},
),
);
}
} else if (order.status == 10) {
// Fiat has been sent already
if (order.is_buyer) {
setSentButton(false);
setReceivedButton(false);
setText(t('Wait for the seller to confirm he has received the payment.'));
} else {
setSentButton(false);
setReceivedButton(true);
setText(t("The buyer has sent the fiat. Click 'Confirm Received' once you receive it."));
}
}
}, [order]);
return (
<Grid
container
padding={0}
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0}
>
<Grid item>
<Typography variant='body2' align='center'>
{text}
</Typography>
</Grid>
<Grid item>
<EncryptedChat
status={order.status}
chatOffset={order.chat_last_index}
orderId={order.id}
takerNick={order.taker_nick}
makerNick={order.maker_nick}
userNick={order.ur_nick}
baseUrl={baseUrl}
messages={messages}
setMessages={setMessages}
/>
</Grid>
<Grid item>
<Tooltip
placement='top'
componentsProps={{
tooltip: { sx: { position: 'relative', top: '3em' } },
}}
disableHoverListener={enableDisputeButton}
disableTouchListener={enableDisputeButton}
enterTouchDelay={0}
title={
<Countdown date={new Date(enableDisputeTime)} renderer={disputeCountdownRenderer} />
}
>
<div>
<LoadingButton
loading={loadingDispute}
disabled={!enableDisputeButton}
color='inherit'
onClick={onClickDispute}
>
{t('Open Dispute')}
</LoadingButton>
</div>
</Tooltip>
</Grid>
<Grid item padding={0.5}>
{sentButton ? (
<Collapse in={sentButton}>
<LoadingButton
loading={loadingSent}
variant='contained'
color='secondary'
onClick={onClickConfirmSent}
>
{t('Confirm {{amount}} {{currencyCode}} sent', { currencyCode, amount })}
</LoadingButton>
</Collapse>
) : (
<></>
)}
{receivedButton ? (
<Collapse in={receivedButton}>
<LoadingButton
loading={loadingReceived}
variant='contained'
color='secondary'
onClick={onClickConfirmReceived}
>
{t('Confirm {{amount}} {{currencyCode}} received', { currencyCode, amount })}
</LoadingButton>
</Collapse>
) : (
<></>
)}
</Grid>
</Grid>
);
};
export default ChatPrompt;

View File

@ -0,0 +1,48 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, Typography } from '@mui/material';
import { DisputeForm, DisputeStatementForm } from '../Forms';
interface DisputePromptProps {
loading: boolean;
dispute: DisputeForm;
setDispute: (state: DisputeForm) => void;
onClickSubmit: () => void;
}
export const DisputePrompt = ({
loading,
dispute,
onClickSubmit,
setDispute,
}: DisputePromptProps): JSX.Element => {
const { t } = useTranslation();
return (
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0}
padding={1}
>
<Grid item>
<Typography variant='body2'>
{t(
'Please, submit your statement. Be clear and specific about what happened and provide the necessary evidence. You MUST provide a contact method: burner email, XMPP or telegram username to follow up with the staff. Disputes are solved at the discretion of real robots (aka humans), so be as helpful as possible to ensure a fair outcome. Max 5000 chars.',
)}
</Typography>
</Grid>
<Grid item>
<DisputeStatementForm
loading={loading}
onClickSubmit={onClickSubmit}
dispute={dispute}
setDispute={setDispute}
/>
</Grid>
</Grid>
);
};
export default DisputePrompt;

View File

@ -0,0 +1,22 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { List, ListItem, Divider, Typography } from '@mui/material';
export const DisputeLoserPrompt = (): JSX.Element => {
const { t } = useTranslation();
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2'>
{t(
'Unfortunately you have lost the dispute. If you think this is a mistake you can ask to re-open the case via email to robosats@protonmail.com. However, chances of it being investigated again are low.',
)}
</Typography>
</ListItem>
</List>
);
};
export default DisputeLoserPrompt;

View File

@ -0,0 +1,29 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { List, ListItem, Divider, Typography } from '@mui/material';
export const DisputeWaitPeerPrompt = (): JSX.Element => {
const { t } = useTranslation();
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2'>
{t(
'We are waiting for your trade counterpart statement. If you are hesitant about the state of the dispute or want to add more information, contact robosats@protonmail.com.',
)}
</Typography>
</ListItem>
<ListItem>
<Typography variant='body2'>
{t(
'Please, save the information needed to identify your order and your payments: order ID; payment hashes of the bonds or escrow (check on your lightning wallet); exact amount of satoshis; and robot nickname. You will have to identify yourself as the user involved in this trade via email (or other contact methods).',
)}
</Typography>
</ListItem>
</List>
);
};
export default DisputeWaitPeerPrompt;

View File

@ -0,0 +1,29 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { List, ListItem, Divider, Typography } from '@mui/material';
export const DisputeWaitResolutionPrompt = (): JSX.Element => {
const { t } = useTranslation();
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2'>
{t(
'Both statements have been received, wait for the staff to resolve the dispute. If you are hesitant about the state of the dispute or want to add more information, contact robosats@protonmail.com. If you did not provide a contact method, or are unsure whether you wrote it right, write us immediately.',
)}
</Typography>
</ListItem>
<ListItem>
<Typography variant='body2'>
{t(
'Please, save the information needed to identify your order and your payments: order ID; payment hashes of the bonds or escrow (check on your lightning wallet); exact amount of satoshis; and robot nickname. You will have to identify yourself as the user involved in this trade via email (or other contact methods).',
)}
</Typography>
</ListItem>
</List>
);
};
export default DisputeWaitResolutionPrompt;

View File

@ -0,0 +1,22 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { List, ListItem, Divider, Typography } from '@mui/material';
export const DisputeWinnerPrompt = (): JSX.Element => {
const { t } = useTranslation();
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2'>
{t(
'You can claim the dispute resolution amount (escrow and fidelity bond) from your profile rewards. If there is anything the staff can help with, do not hesitate to contact to robosats@protonmail.com (or via your provided burner contact method).',
)}
</Typography>
</ListItem>
</List>
);
};
export default DisputeWinnerPrompt;

View File

@ -0,0 +1,26 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { List, ListItem, Divider, Typography } from '@mui/material';
export const EscrowWaitPrompt = (): JSX.Element => {
const { t } = useTranslation();
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2' align='left'>
{t('We are waiting for the seller to lock the trade amount.')}
</Typography>
</ListItem>
<ListItem>
<Typography variant='body2' align='left'>
{t(
'Just hang on for a moment. If the seller does not deposit, you will get your bond back automatically. In addition, you will receive a compensation (check the rewards in your profile).',
)}
</Typography>
</ListItem>
</List>
);
};
export default EscrowWaitPrompt;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, Typography } from '@mui/material';
import { LoadingButton } from '@mui/lab';
import { Order } from '../../../models';
interface ExpiredPromptProps {
order: Order;
loadingRenew: boolean;
onClickRenew: () => void;
}
export const ExpiredPrompt = ({
loadingRenew,
order,
onClickRenew,
}: ExpiredPromptProps): JSX.Element => {
const { t } = useTranslation();
return (
<Grid container spacing={1}>
<Grid item xs={12}>
<Typography variant='body2' align='center'>
{t(order.expiry_message)}
</Typography>
</Grid>
{order.is_maker ? (
<Grid item xs={12} style={{ display: 'flex', justifyContent: 'center' }}>
<LoadingButton
loading={loadingRenew}
variant='outlined'
color='primary'
onClick={onClickRenew}
>
{t('Renew Order')}
</LoadingButton>
</Grid>
) : (
<></>
)}
</Grid>
);
};
export default ExpiredPrompt;

View File

@ -1,12 +1,12 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Grid, Link, Typography, TextField, Tooltip, useTheme } from '@mui/material';
import { AccountBalanceWallet, ContentCopy } from '@mui/icons-material';
import { NewTabIcon } from '../../Icons';
import { Button, Box, Grid, Typography, TextField, Tooltip, useTheme } from '@mui/material';
import { ContentCopy } from '@mui/icons-material';
import QRCode from 'react-qr-code';
import { Order } from '../../../models';
import { systemClient } from '../../../services/System';
import currencies from '../../../../static/assets/currencies.json';
import WalletsButton from '../WalletsButton';
interface LockInvoicePromptProps {
order: Order;
@ -19,6 +19,7 @@ export const LockInvoicePrompt = ({ order, concept }: LockInvoicePromptProps): J
const currencyCode: string = currencies[`${order.currency}`];
const invoice = concept === 'bond' ? order.bond_invoice : order.escrow_invoice;
const helperText =
concept === 'bond'
? t(
@ -29,22 +30,6 @@ export const LockInvoicePrompt = ({ order, concept }: LockInvoicePromptProps): J
{ currencyCode },
);
const CompatibleWalletsButton = function () {
return (
<Button
color='primary'
component={Link}
href={'https://learn.robosats.com/docs/wallets/'}
target='_blank'
align='center'
>
<AccountBalanceWallet />
{t('See Compatible Wallets')}
<NewTabIcon sx={{ width: '1.1em', height: '1.1em' }} />
</Button>
);
};
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 (
<Grid container spacing={1}>
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0.5}
>
{order.is_taker && concept == 'bond' ? (
<Typography color='secondary'>
<b>{t(`You are ${order.is_buyer ? 'BUYING' : 'SELLING'} BTC`)}</b>
</Typography>
) : (
<></>
)}
<Grid item xs={12}>
{concept === 'bond' ? <CompatibleWalletsButton /> : <ExpirationWarning />}
{concept === 'bond' ? <WalletsButton /> : <ExpirationWarning />}
</Grid>
<Grid item xs={12}>
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!')}>
<QRCode
bgColor={'rgba(255, 255, 255, 0)'}
fgColor={theme.palette.text.primary}
value={invoice}
size={theme.typography.fontSize * 21.8}
onClick={() => {
systemClient.copyToClipboard(invoice);
<Box
sx={{
display: 'flex',
backgroundColor: theme.palette.background.paper,
alignItems: 'center',
justifyContent: 'center',
padding: '0.5em',
borderRadius: '0.3em',
}}
/>
>
<QRCode
bgColor={'rgba(255, 255, 255, 0)'}
fgColor={theme.palette.text.primary}
value={invoice ?? 'Undefined: BOLT11 invoice not received'}
size={theme.typography.fontSize * 21.8}
onClick={() => {
systemClient.copyToClipboard(invoice);
}}
/>
</Box>
</Tooltip>
</Grid>
<Grid item xs={12}>
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!')}>
<Button
size='small'
@ -101,7 +112,7 @@ export const LockInvoicePrompt = ({ order, concept }: LockInvoicePromptProps): J
hiddenLabel
variant='standard'
size='small'
defaultValue={invoice}
defaultValue={invoice ?? 'Undefined: BOLT11 invoice not received'}
disabled={true}
helperText={helperText}
color='secondary'

View File

@ -0,0 +1,40 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { List, ListItem, Divider, Grid, Typography } from '@mui/material';
import { LoadingButton } from '@mui/lab';
import { PlayCircle } from '@mui/icons-material';
interface PausedPrompProps {
pauseLoading: boolean;
onClickResumeOrder: () => void;
}
export const PausedPrompt = ({
pauseLoading,
onClickResumeOrder,
}: PausedPrompProps): JSX.Element => {
const { t } = useTranslation();
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2' align='left'>
{t(
'Your public order has been paused. At the moment it cannot be seen or taken by other robots. You can choose to unpause it at any time.',
)}
</Typography>
</ListItem>
<Grid item xs={12} style={{ display: 'flex', justifyContent: 'center' }}>
<LoadingButton loading={pauseLoading} color='primary' onClick={onClickResumeOrder}>
<PlayCircle sx={{ width: '1.6em', height: '1.6em' }} />
{t('Unpause Order')}
</LoadingButton>
</Grid>
</List>
);
};
export default PausedPrompt;

View File

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, Typography, ToggleButtonGroup, ToggleButton } from '@mui/material';
import currencies from '../../../../static/assets/currencies.json';
import { Order } from '../../../models';
import { pn } from '../../../utils';
import { Bolt, Link } from '@mui/icons-material';
import { LightningPayoutForm, LightningForm, OnchainPayoutForm, OnchainForm } from '../Forms';
interface PayoutPrompProps {
order: Order;
onClickSubmitInvoice: (invoice: string) => void;
lightning: LightningForm;
loadingLightning: boolean;
setLightning: (state: LightningForm) => void;
onClickSubmitAddress: () => void;
onchain: OnchainForm;
setOnchain: (state: OnchainForm) => void;
loadingOnchain: boolean;
}
export const PayoutPrompt = ({
order,
onClickSubmitInvoice,
loadingLightning,
lightning,
setLightning,
onClickSubmitAddress,
loadingOnchain,
onchain,
setOnchain,
}: PayoutPrompProps): JSX.Element => {
const { t } = useTranslation();
const currencyCode: string = currencies[`${order.currency}`];
const [tab, setTab] = useState<'lightning' | 'onchain'>('lightning');
return (
<Grid
container
padding={1}
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={1}
>
<Grid item>
<Typography variant='body2'>
{t(
'Before letting you send {{amountFiat}} {{currencyCode}}, we want to make sure you are able to receive the BTC.',
{
amountFiat: pn(
parseFloat(parseFloat(order.amount).toFixed(order.currency == 1000 ? 8 : 4)),
),
currencyCode,
},
)}
</Typography>
</Grid>
<Grid item>
<ToggleButtonGroup
size='small'
value={tab}
exclusive
onChange={(mouseEvent, value: string) => setTab(value)}
>
<ToggleButton value='lightning' disableRipple={true}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
<Bolt /> {t('Lightning')}
</div>
</ToggleButton>
<ToggleButton value='onchain' disabled={!order.swap_allowed}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
<Link /> {t('Onchain')}
</div>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
<Grid item style={{ display: tab == 'lightning' ? '' : 'none' }}>
<LightningPayoutForm
order={order}
loading={loadingLightning}
lightning={lightning}
setLightning={setLightning}
onClickSubmit={onClickSubmitInvoice}
/>
</Grid>
{/* ONCHAIN PAYOUT TAB */}
<Grid item style={{ display: tab == 'onchain' ? '' : 'none' }}>
<OnchainPayoutForm
order={order}
loading={loadingOnchain}
onchain={onchain}
setOnchain={setOnchain}
onClickSubmit={onClickSubmitAddress}
/>
</Grid>
</Grid>
);
};
export default PayoutPrompt;

View File

@ -0,0 +1,30 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { List, ListItem, Divider, Typography } from '@mui/material';
export const PayoutWaitPrompt = (): JSX.Element => {
const { t } = useTranslation();
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2' align='left'>
{t(
'We are waiting for the buyer to post a lightning invoice. Once he does, you will be able to directly communicate the fiat payment details.',
)}
</Typography>
</ListItem>
<ListItem>
<Typography variant='body2' align='left'>
{t(
'Just hang on for a moment. If the buyer does not cooperate, you will get back the trade collateral and your bond automatically. In addition, you will receive a compensation (check the rewards in your profile).',
)}
</Typography>
</ListItem>
</List>
);
};
export default PayoutWaitPrompt;

View File

@ -0,0 +1,112 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
List,
ListItem,
ListItemText,
ListItemIcon,
Divider,
Grid,
Typography,
Tooltip,
} from '@mui/material';
import { LoadingButton } from '@mui/lab';
import currencies from '../../../../static/assets/currencies.json';
import { Order } from '../../../models';
import { PauseCircle, Storefront, Percent } from '@mui/icons-material';
interface PublicWaitPrompProps {
order: Order;
pauseLoading: boolean;
onClickPauseOrder: () => void;
}
export const PublicWaitPrompt = ({
order,
pauseLoading,
onClickPauseOrder,
}: PublicWaitPrompProps): JSX.Element => {
const { t } = useTranslation();
const currencyCode = currencies[order.currency.toString()];
const depositHoursMinutes = function () {
const hours = Math.floor(order.escrow_duration / 3600);
const minutes = Math.floor((order.escrow_duration - hours * 3600) / 60);
const dict = { deposit_timer_hours: hours, deposit_timer_minutes: minutes };
return dict;
};
return (
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2' align='left'>
{t(
'Be patient while robots check the book. This box will ring 🔊 once a robot takes your order, then you will have {{deposit_timer_hours}}h {{deposit_timer_minutes}}m to reply. If you do not reply, you risk losing your bond.',
depositHoursMinutes(),
)}
</Typography>
</ListItem>
<ListItem>
<Typography variant='body2' align='left'>
{t('If the order expires untaken, your bond will return to you (no action needed).')}
</Typography>
</ListItem>
<Divider />
<Grid container>
<Grid item xs={10}>
<ListItem>
<ListItemIcon>
<Storefront />
</ListItemIcon>
<ListItemText
primary={order.num_similar_orders}
secondary={t('Public orders for {{currencyCode}}', {
currencyCode,
})}
/>
</ListItem>
</Grid>
<Grid item xs={2}>
<div style={{ position: 'relative', top: '0.5em', right: '1em' }}>
<Tooltip
placement='top'
enterTouchDelay={500}
enterDelay={700}
enterNextDelay={2000}
title={t('Pause the public order')}
>
<div>
<LoadingButton loading={pauseLoading} color='primary' onClick={onClickPauseOrder}>
<PauseCircle sx={{ width: '1.6em', height: '1.6em' }} />
</LoadingButton>
</div>
</Tooltip>
</div>
</Grid>
</Grid>
<Divider />
<ListItem>
<ListItemIcon>
<Percent />
</ListItemIcon>
<ListItemText
primary={`${t('Premium rank')} ${Math.floor(order.premium_percentile * 100)}%`}
secondary={t('Among public {{currencyCode}} orders (higher is cheaper)', {
currencyCode,
})}
/>
</ListItem>
</List>
);
};
export default PublicWaitPrompt;

View File

@ -0,0 +1,140 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, CircularProgress, Grid, Typography, useTheme } from '@mui/material';
import Countdown, { CountdownRenderProps, zeroPad } from 'react-countdown';
import { Order } from '../../../models';
import { LightningForm, LightningPayoutForm } from '../Forms';
interface RoutingFailedPromptProps {
order: Order;
onClickSubmitInvoice: (invoice: string) => void;
lightning: LightningForm;
loadingLightning: boolean;
setLightning: (state: LightningForm) => void;
}
interface FailureReasonProps {
failureReason: string;
}
const FailureReason = ({ failureReason }: FailureReasonProps): JSX.Element => {
const { t } = useTranslation();
const theme = useTheme();
return (
<Box
style={{
backgroundColor: theme.palette.background.paper,
borderRadius: '0.3em',
border: `1px solid ${theme.palette.text.secondary}`,
}}
>
<Typography variant='body2' align='center'>
<b>{t('Failure reason:')}</b>
</Typography>
<Typography variant='body2' align='center'>
{t(failureReason)}
</Typography>
</Box>
);
};
export const RoutingFailedPrompt = ({
order,
onClickSubmitInvoice,
loadingLightning,
lightning,
setLightning,
}: RoutingFailedPromptProps): JSX.Element => {
const { t } = useTranslation();
const countdownRenderer = function ({ minutes, seconds, completed }: CountdownRenderProps) {
if (completed) {
return (
<Grid container direction='column' alignItems='center' justifyContent='center' spacing={1}>
<Grid item>
<Typography>{t('Retrying!')}</Typography>
</Grid>
<Grid item>
<CircularProgress />
</Grid>
</Grid>
);
} else {
return <span>{`${zeroPad(minutes)}m ${zeroPad(seconds)}s `}</span>;
}
};
if (order.invoice_expired && order.failure_reason) {
return (
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0.5}
padding={1}
>
<Grid item>
<Typography variant='body2'>
{t(
'Your invoice has expired or more than 3 payment attempts have been made. Submit a new invoice.',
)}
</Typography>
</Grid>
{order.failure_reason ? (
<Grid item>
<FailureReason failureReason={order.failure_reason} />
</Grid>
) : (
<></>
)}
<Grid item>
<LightningPayoutForm
order={order}
loading={loadingLightning}
lightning={lightning}
setLightning={setLightning}
onClickSubmit={onClickSubmitInvoice}
/>
</Grid>
</Grid>
);
} else {
return (
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={1}
padding={1}
>
<Grid item>
<FailureReason failureReason={order.failure_reason} />
</Grid>
<Grid item>
<Typography variant='body2'>
{t(
'RoboSats will try to pay your invoice 3 times with a one minute pause in between. If it keeps failing, you will be able to submit a new invoice. Check whether you have enough inbound liquidity. Remember that lightning nodes must be online in order to receive payments.',
)}
</Typography>
</Grid>
<div style={{ height: '0.6em' }} />
<Grid item>
<Typography align='center'>
<b>{t('Next attempt in')}</b>
</Typography>
</Grid>
<Grid item>
<Countdown date={new Date(order.next_retry_time)} renderer={countdownRenderer} />
</Grid>
</Grid>
);
}
};
export default RoutingFailedPrompt;

View File

@ -0,0 +1,31 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Grid, Typography, CircularProgress } from '@mui/material';
export const SendingSatsPrompt = (): JSX.Element => {
const { t } = useTranslation();
return (
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={1}
padding={1}
>
<Grid item>
<Typography variant='body2'>
{t(
'RoboSats is trying to pay your lightning invoice. Remember that lightning nodes must be online in order to receive payments.',
)}
</Typography>
</Grid>
<Grid item>
<CircularProgress />
</Grid>
</Grid>
);
};
export default SendingSatsPrompt;

View File

@ -0,0 +1,198 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Grid,
Typography,
Rating,
Collapse,
Link,
Alert,
AlertTitle,
Tooltip,
IconButton,
Button,
} from '@mui/material';
import currencies from '../../../../static/assets/currencies.json';
import TradeSummary from '../TradeSummary';
import { Favorite, RocketLaunch, ContentCopy, Refresh } from '@mui/icons-material';
import { LoadingButton } from '@mui/lab';
import { Trans } from 'react-i18next';
import { Order } from '../../../models';
import { systemClient } from '../../../services/System';
interface SuccessfulPromptProps {
order: Order;
ratePlatform: (rating: number) => void;
onClickStartAgain: () => void;
onClickRenew: () => void;
loadingRenew: boolean;
baseUrl: string;
}
export const SuccessfulPrompt = ({
order,
ratePlatform,
onClickStartAgain,
onClickRenew,
loadingRenew,
baseUrl,
}: SuccessfulPromptProps): JSX.Element => {
const { t } = useTranslation();
const currencyCode: string = currencies[`${order.currency}`];
const [rating, setRating] = useState<number | undefined>(undefined);
return (
<Grid
container
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0.5}
padding={1}
>
<Grid item xs={12}>
<Typography variant='body2' align='center'>
<Trans i18nKey='rate_robosats'>
What do you think of <b>RoboSats</b>?
</Trans>
</Typography>
</Grid>
<Grid item>
<Rating
name='size-large'
defaultValue={0}
size='large'
onChange={(e) => {
const rate = e.target.value;
ratePlatform(rate);
setRating(rate);
}}
/>
</Grid>
{rating == 5 ? (
<Grid item xs={12}>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
justifyContent: 'center',
}}
>
<Typography variant='body2' align='center'>
<b>{t('Thank you! RoboSats loves you too')}</b>
</Typography>
<Favorite color='error' />
</div>
<Typography variant='body2' align='center'>
{t(
'RoboSats gets better with more liquidity and users. Tell a bitcoiner friend about Robosats!',
)}
</Typography>
</Grid>
) : rating != undefined ? (
<Grid>
<Typography variant='body2' align='center'>
<b>{t('Thank you for using Robosats!')}</b>
</Typography>
<Typography variant='body2' align='center'>
<Trans i18nKey='let_us_know_hot_to_improve'>
Let us know how the platform could improve (
<Link target='_blank' href='https://t.me/robosats'>
Telegram
</Link>
{' / '}
<Link target='_blank' href='https://github.com/Reckless-Satoshi/robosats/issues'>
Github
</Link>
)
</Trans>
</Typography>
</Grid>
) : (
<></>
)}
{/* SHOW TXID IF USER RECEIVES ONCHAIN */}
<Collapse in={order.txid != undefined}>
<Alert severity='success'>
<AlertTitle>
{t('Your TXID')}
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!')}>
<IconButton
color='inherit'
onClick={() => {
systemClient.copyToClipboard(order.txid);
}}
>
<ContentCopy sx={{ width: '1em', height: '1em' }} />
</IconButton>
</Tooltip>
</AlertTitle>
<Typography
variant='body2'
align='center'
sx={{ wordWrap: 'break-word', width: '15.71em' }}
>
<Link
target='_blank'
href={
'http://mempoolhqx4isw62xs7abwphsq7ldayuidyx2v2oethdhhj6mlo2r6ad.onion/' +
(order.network == 'testnet' ? 'testnet/' : '') +
'tx/' +
order.txid
}
>
{order.txid}
</Link>
</Typography>
</Alert>
</Collapse>
<Grid item container alignItems='center' justifyContent='space-evenly'>
<Grid item>
<Button color='primary' variant='outlined' onClick={onClickStartAgain}>
<RocketLaunch sx={{ width: '0.8em' }} />
<Typography style={{ display: 'inline-block' }}>{t('Start Again')}</Typography>
</Button>
</Grid>
{order.is_maker ? (
<Grid item>
<LoadingButton
color='primary'
variant='outlined'
onClick={onClickRenew}
loading={loadingRenew}
>
<Refresh sx={{ width: '0.8em' }} />
<Typography style={{ display: 'inline-block' }}>{t('Renew')}</Typography>
</LoadingButton>
</Grid>
) : null}
</Grid>
{order.platform_summary ? (
<Grid item>
<TradeSummary
isMaker={order.is_maker}
makerNick={order.maker_nick}
takerNick={order.taker_nick}
currencyCode={currencyCode}
makerSummary={order.maker_summary}
takerSummary={order.taker_summary}
platformSummary={order.platform_summary}
orderId={order.id}
baseUrl={baseUrl}
/>
</Grid>
) : (
<></>
)}
</Grid>
);
};
export default SuccessfulPrompt;

View File

@ -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 (
<Typography color='primary' variant='subtitle1'>
<b>{t('A taker has been found!')}</b>
{` ${stepXofY(order)}`}
</Typography>
);
};
return (
<Grid container spacing={1}>
<Grid item>
<List dense={true}>
<Divider />
<ListItem>
<Typography variant='body2'>
{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.',
)}
</Typography>
</Grid>
</Grid>
</ListItem>
</List>
);
};

View File

@ -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';

View File

@ -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) });
let textColor = color;
if (color == 'warning') {
textColor = theme.palette.warning.main;
} else if (color == 'success') {
textColor = theme.palette.success.main;
}
{
/* Maker and taker Bond request */
}
// {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 (
<Typography variant='body2'>
<b>{text}</b>
{stepXofY(order)}
<Typography
color={textColor}
variant='subtitle1'
align='center'
style={{ display: 'flex', alignItems: 'center' }}
>
{icon()}
<span>
<b>{t(text, variables)}</b> {stepXofY(order)}
</span>
{icon()}
</Typography>
);
};

View File

@ -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<string, Item>;
takerSummary: Record<string, Item>;
platformSummary: Record<string, Item>;
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<number>(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 (
<Grid item xs={12}>
<Accordion
defaultExpanded={true}
elevation={0}
sx={{ width: 322, position: 'relative', right: 8 }}
<Box
sx={{
backgroundColor: theme.palette.background.paper,
borderRadius: '0.3em',
padding: '0.5em',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography align='center' color='text.secondary'>
{t('Trade Summary')}
</Typography>
<Tooltip enterTouchDelay={250} title={t('Export trade summary')}>
<IconButton color='primary' onClick={onClickExport}>
<ExportIcon sx={{ width: '0.8em', height: '0.8em' }} />
</IconButton>
</Tooltip>
</div>
<div
style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon sx={{ width: 28 }} color='primary' />}>
<Typography sx={{ flexGrow: 1 }} color='text.secondary'>
{t('Trade Summary')}
</Typography>
</AccordionSummary>
<AccordionDetails>
<div
style={{
position: 'relative',
left: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
<ToggleButtonGroup size='small' value={buttonValue} exclusive>
<ToggleButton value={0} disableRipple={true} onClick={() => setButtonValue(0)}>
<RobotAvatar
baseUrl={baseUrl}
style={{ height: 28, width: 28 }}
nickname={makerNick}
/>
&nbsp;
{t('Maker')}
</ToggleButton>
<ToggleButton value={1} disableRipple={true} onClick={() => setButtonValue(1)}>
<RoboSatsNoTextIcon />
</ToggleButton>
<ToggleButton value={2} disableRipple={true} onClick={() => setButtonValue(2)}>
{t('Taker')}
&nbsp;
<RobotAvatar
baseUrl={baseUrl}
avatarClass='smallAvatar'
style={{ height: 28, width: 28 }}
nickname={takerNick}
/>
</ToggleButton>
</ToggleButtonGroup>
<Tooltip enterTouchDelay={250} title={t('Save trade summary as file')}>
<span>
<IconButton
color='primary'
onClick={() =>
saveAsJson(`order${orderId}-summary.json`, {
order_id: orderId,
currency: currencyCode,
maker: makerSummary,
taker: takerSummary,
platform: platformSummary,
})
}
>
<DownloadIcon sx={{ width: 26, height: 26 }} />
</IconButton>
</span>
</Tooltip>
</div>
{/* Maker/Taker Summary */}
<div style={{ display: [0, 2].includes(buttonValue) ? '' : 'none' }}>
<List dense={true}>
<ListItem>
<ListItemIcon>
<Badge
overlap='circular'
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
badgeContent={
<div style={{ position: 'relative', left: '3px', top: '2px' }}>
{userSummary.is_buyer ? (
<SendReceiveIcon
sx={{ transform: 'scaleX(-1)', height: '18px', width: '18px' }}
color='secondary'
/>
) : (
<SendReceiveIcon sx={{ height: '18px', width: '18px' }} color='primary' />
)}
</div>
}
>
<AccountBoxIcon
sx={{ position: 'relative', left: -2, width: 28, height: 28 }}
/>
</Badge>
</ListItemIcon>
<ListItemText
primary={userSummary.is_buyer ? t('Buyer') : t('Seller')}
secondary={t('User role')}
/>
<ListItemIcon>
<div
style={{
position: 'relative',
left: 15,
zoom: 1.25,
opacity: 0.7,
msZoom: 1.25,
WebkitZoom: 1.25,
MozTransform: 'scale(1.25,1.25)',
MozTransformOrigin: 'left center',
}}
>
<FlagWithProps code={currencyCode} />
<ToggleButtonGroup size='small' value={buttonValue} exclusive>
<ToggleButton value={0} disableRipple={true} onClick={() => setButtonValue(0)}>
<RobotAvatar
baseUrl={baseUrl}
style={{ height: '1.5em', width: '1.5em' }}
nickname={makerNick}
/>
&nbsp;
{t('Maker')}
</ToggleButton>
<ToggleButton value={1} disableRipple={true} onClick={() => setButtonValue(1)}>
<RoboSatsNoTextIcon />
</ToggleButton>
<ToggleButton value={2} disableRipple={true} onClick={() => setButtonValue(2)}>
{t('Taker')}
&nbsp;
<RobotAvatar
baseUrl={baseUrl}
avatarClass='smallAvatar'
style={{ height: '1.5em', width: '1.5em' }}
nickname={takerNick}
/>
</ToggleButton>
</ToggleButtonGroup>
</div>
{/* Maker/Taker Summary */}
<div style={{ display: [0, 2].includes(buttonValue) ? '' : 'none' }}>
<List dense={true}>
<ListItem>
<ListItemIcon>
<Badge
overlap='circular'
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
badgeContent={
<div style={{ position: 'relative', left: '0.1em', top: '0.1em' }}>
{userSummary.is_buyer ? (
<SendReceiveIcon
sx={{ transform: 'scaleX(-1)', height: '0.7em', width: '0.7em' }}
color='secondary'
/>
) : (
<SendReceiveIcon sx={{ height: '0.7em', width: '0.7em' }} color='primary' />
)}
</div>
</ListItemIcon>
<ListItemText
primary={
(userSummary.is_buyer
? pn(userSummary.sent_fiat)
: pn(userSummary.received_fiat)) +
' ' +
currencyCode
}
secondary={userSummary.is_buyer ? t('Sent') : t('Received')}
}
>
<AccountBox
sx={{ position: 'relative', left: '-0.1em', width: '1.5em', height: '1.5em' }}
/>
</ListItem>
</Badge>
</ListItemIcon>
<ListItemText
primary={userSummary.is_buyer ? t('Buyer') : t('Seller')}
secondary={t('User role')}
/>
<ListItem>
<ListItemIcon>
<BitcoinIcon />
</ListItemIcon>
<ListItemText
primary={
pn(userSummary.is_buyer ? userSummary.received_sats : userSummary.sent_sats) +
' Sats'
}
secondary={userSummary.is_buyer ? 'BTC received' : 'BTC sent'}
/>
<ListItemIcon>
<div
style={{
position: 'relative',
left: 15,
zoom: 1.25,
opacity: 0.7,
msZoom: 1.25,
WebkitZoom: 1.25,
MozTransform: 'scale(1.25,1.25)',
MozTransformOrigin: 'left center',
}}
>
<FlagWithProps code={currencyCode} />
</div>
</ListItemIcon>
<ListItemText
primary={
(userSummary.is_buyer ? pn(userSummary.sent_fiat) : pn(userSummary.received_fiat)) +
' ' +
currencyCode
}
secondary={userSummary.is_buyer ? t('Sent') : t('Received')}
/>
</ListItem>
<ListItemText
primary={t('{{tradeFeeSats}} Sats ({{tradeFeePercent}}%)', {
tradeFeeSats: pn(userSummary.trade_fee_sats),
tradeFeePercent: parseFloat(
(userSummary.trade_fee_percent * 100).toPrecision(3),
),
})}
secondary={'Trade fee'}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<BitcoinIcon />
</ListItemIcon>
<ListItemText
primary={
pn(userSummary.is_buyer ? userSummary.received_sats : userSummary.sent_sats) +
' Sats'
}
secondary={userSummary.is_buyer ? 'BTC received' : 'BTC sent'}
/>
{userSummary.is_swap ? (
<ListItem>
<ListItemIcon>
<LinkIcon />
</ListItemIcon>
<ListItemText
primary={t('{{swapFeeSats}} Sats ({{swapFeePercent}}%)', {
swapFeeSats: pn(userSummary.swap_fee_sats),
swapFeePercent: userSummary.swap_fee_percent,
})}
secondary={t('Onchain swap fee')}
/>
<ListItemText
primary={t('{{miningFeeSats}} Sats', {
miningFeeSats: pn(userSummary.mining_fee_sats),
})}
secondary={t('Mining fee')}
/>
</ListItem>
) : null}
<ListItemText
primary={t('{{tradeFeeSats}} Sats ({{tradeFeePercent}}%)', {
tradeFeeSats: pn(userSummary.trade_fee_sats),
tradeFeePercent: parseFloat((userSummary.trade_fee_percent * 100).toPrecision(3)),
})}
secondary={'Trade fee'}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<LockOpenIcon color='success' />
</ListItemIcon>
<ListItemText
primary={t('{{bondSats}} Sats ({{bondPercent}}%)', {
bondSats: pn(userSummary.bond_size_sats),
bondPercent: userSummary.bond_size_percent,
})}
secondary={buttonValue === 0 ? t('Maker bond') : t('Taker bond')}
/>
<ListItemText sx={{ color: '#2e7d32' }} primary={<b>{t('Unlocked')}</b>} />
</ListItem>
</List>
</div>
{/* Platform Summary */}
<div style={{ display: buttonValue == 1 ? '' : 'none' }}>
<List dense={true}>
<ListItem>
<ListItemIcon>
<AccountBalanceIcon />
</ListItemIcon>
<ListItemText
primary={t('{{revenueSats}} Sats', {
revenueSats: pn(platformSummary.trade_revenue_sats),
})}
secondary={t('Platform trade revenue')}
/>
</ListItem>
{userSummary.is_swap ? (
<ListItem>
<ListItemIcon>
<Link />
</ListItemIcon>
<ListItemText
primary={t('{{swapFeeSats}} Sats ({{swapFeePercent}}%)', {
swapFeeSats: pn(userSummary.swap_fee_sats),
swapFeePercent: userSummary.swap_fee_percent,
})}
secondary={t('Onchain swap fee')}
/>
<ListItemText
primary={t('{{miningFeeSats}} Sats', {
miningFeeSats: pn(userSummary.mining_fee_sats),
})}
secondary={t('Mining fee')}
/>
</ListItem>
) : null}
<ListItem>
<ListItemIcon>
<RouteIcon />
</ListItemIcon>
<ListItemText
primary={t('{{routingFeeSats}} MiliSats', {
routingFeeSats: pn(platformSummary.routing_fee_sats),
})}
secondary={t('Platform covered routing fee')}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<LockOpen color='success' />
</ListItemIcon>
<ListItemText
primary={t('{{bondSats}} Sats ({{bondPercent}}%)', {
bondSats: pn(userSummary.bond_size_sats),
bondPercent: userSummary.bond_size_percent,
})}
secondary={buttonValue === 0 ? t('Maker bond') : t('Taker bond')}
/>
<ListItemText
sx={{ color: theme.palette.success.main }}
primary={<b>{t('Unlocked')}</b>}
/>
</ListItem>
</List>
</div>
{/* Platform Summary */}
<div style={{ display: buttonValue == 1 ? '' : 'none' }}>
<List dense={true}>
<ListItem>
<ListItemIcon>
<AccountBalance />
</ListItemIcon>
<ListItemText
primary={t('{{revenueSats}} Sats', {
revenueSats: pn(platformSummary.trade_revenue_sats),
})}
secondary={t('Platform trade revenue')}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<PriceChangeIcon />
</ListItemIcon>
<ListItemText
primary={`${pn(
platformSummary.contract_exchange_rate.toPrecision(7),
)} ${currencyCode}/BTC`}
secondary={t('Contract exchange rate')}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<Route />
</ListItemIcon>
<ListItemText
primary={t('{{routingFeeSats}} MiliSats', {
routingFeeSats: pn(platformSummary.routing_fee_sats),
})}
secondary={t('Platform covered routing fee')}
/>
</ListItem>
<ListItem>
<ListItemText
primary={format(contractTimestamp, 'do LLL HH:mm:ss')}
secondary={t('Timestamp')}
/>
<ListItemIcon>
<ScheduleIcon />
</ListItemIcon>
<ListItemText
primary={`${String(hours).padStart(2, '0')}:${String(mins).padStart(
2,
'0',
)}:${String(secs).padStart(2, '0')}`}
secondary={t('Completed in')}
/>
</ListItem>
</List>
</div>
</AccordionDetails>
</Accordion>
</Grid>
<ListItem>
<ListItemIcon>
<PriceChange />
</ListItemIcon>
<ListItemText
primary={`${pn(
platformSummary.contract_exchange_rate.toPrecision(7),
)} ${currencyCode}/BTC`}
secondary={t('Contract exchange rate')}
/>
</ListItem>
<ListItem>
<ListItemText
primary={format(contractTimestamp, 'do LLL HH:mm:ss')}
secondary={t('Timestamp')}
/>
<ListItemIcon>
<Schedule />
</ListItemIcon>
<ListItemText
primary={`${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(
secs,
).padStart(2, '0')}`}
secondary={t('Completed in')}
/>
</ListItem>
</List>
</div>
</Box>
);
};

View File

@ -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 (
<Button
color='primary'
component={Link}
href={'https://learn.robosats.com/docs/wallets/'}
target='_blank'
align='center'
>
<AccountBalanceWallet />
{t('See Compatible Wallets')}
<NewTabIcon sx={{ width: '0.7em', height: '0.7em' }} />
</Button>
);
};
export default WalletsButton;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,687 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Divider, Grid } from '@mui/material';
import { apiClient } from '../../services/api';
import { getWebln, pn } from '../../utils';
import {
ConfirmCancelDialog,
ConfirmCollabCancelDialog,
ConfirmDisputeDialog,
ConfirmFiatReceivedDialog,
WebLNDialog,
} from './Dialogs';
import Title from './Title';
import {
LockInvoicePrompt,
TakerFoundPrompt,
PublicWaitPrompt,
PausedPrompt,
ExpiredPrompt,
PayoutPrompt,
PayoutWaitPrompt,
EscrowWaitPrompt,
ChatPrompt,
DisputePrompt,
DisputeWaitPeerPrompt,
DisputeWaitResolutionPrompt,
SendingSatsPrompt,
SuccessfulPrompt,
RoutingFailedPrompt,
DisputeWinnerPrompt,
DisputeLoserPrompt,
} from './Prompts';
import BondStatus from './BondStatus';
import CancelButton from './CancelButton';
import {
defaultLightning,
LightningForm,
defaultOnchain,
OnchainForm,
DisputeForm,
defaultDispute,
} from './Forms';
import { Order } from '../../models';
import { EncryptedChatMessage } from './EncryptedChat';
import { systemClient } from '../../services/System';
import CollabCancelAlert from './CollabCancelAlert';
import { Bolt } from '@mui/icons-material';
interface loadingButtonsProps {
cancel: boolean;
fiatSent: boolean;
fiatReceived: boolean;
submitInvoice: boolean;
submitAddress: boolean;
submitStatement: boolean;
openDispute: boolean;
pauseOrder: boolean;
renewOrder: boolean;
}
const noLoadingButtons: loadingButtonsProps = {
cancel: false,
fiatSent: false,
fiatReceived: false,
submitInvoice: false,
submitAddress: false,
submitStatement: false,
openDispute: false,
pauseOrder: false,
renewOrder: false,
};
interface OpenDialogProps {
confirmCancel: boolean;
confirmCollabCancel: boolean;
confirmFiatReceived: boolean;
confirmDispute: boolean;
webln: boolean;
}
const closeAll: OpenDialogProps = {
confirmCancel: false,
confirmCollabCancel: false,
confirmFiatReceived: false,
confirmDispute: false,
webln: false,
};
interface TradeBoxProps {
order: Order;
setOrder: (state: Order) => void;
setBadOrder: (state: string | undefined) => void;
onRenewOrder: () => void;
onStartAgain: () => void;
baseUrl: string;
}
const TradeBox = ({
order,
setOrder,
baseUrl,
setBadOrder,
onRenewOrder,
onStartAgain,
}: TradeBoxProps): JSX.Element => {
const { t } = useTranslation();
// Buttons and Dialogs
const [loadingButtons, setLoadingButtons] = useState<loadingButtonsProps>(noLoadingButtons);
const [open, setOpen] = useState<OpenDialogProps>(closeAll);
const [waitingWebln, setWaitingWebln] = useState<boolean>(false);
const [lastOrderStatus, setLastOrderStatus] = useState<number>(-1);
// Forms
const [onchain, setOnchain] = useState<OnchainForm>(defaultOnchain);
const [lightning, setLightning] = useState<LightningForm>(defaultLightning);
const [dispute, setDispute] = useState<DisputeForm>(defaultDispute);
// Chat
const [messages, setMessages] = useState<EncryptedChatMessage[]>([]);
interface SubmitActionProps {
action:
| 'cancel'
| 'dispute'
| 'pause'
| 'confirm'
| 'update_invoice'
| 'update_address'
| 'submit_statement'
| 'rate_platform';
invoice?: string;
address?: string;
mining_fee_rate?: number;
statement?: string;
rating?: number;
}
const submitAction = function ({
action,
invoice,
address,
mining_fee_rate,
statement,
rating,
}: SubmitActionProps) {
apiClient
.post(baseUrl, '/api/order/?order_id=' + order.id, {
action,
invoice,
address,
mining_fee_rate,
statement,
rating,
})
.catch(() => {
setOpen(closeAll);
setLoadingButtons({ ...noLoadingButtons });
})
.then((data: Order) => {
setOpen(closeAll);
setLoadingButtons({ ...noLoadingButtons });
if (data.bad_request) {
setBadOrder(data.bad_request);
} else if (data.bad_address) {
setOnchain({ ...onchain, badAddress: data.bad_address });
} else if (data.bad_invoice) {
setLightning({ ...lightning, badInvoice: data.bad_invoice });
} else if (data.bad_statement) {
setDispute({ ...dispute, badStatement: data.bad_statement });
} else {
setOrder({ ...order, ...data });
setBadOrder(undefined);
}
});
};
const cancel = function () {
setLoadingButtons({ ...noLoadingButtons, cancel: true });
submitAction({ action: 'cancel' });
};
const openDispute = function () {
setLoadingButtons({ ...noLoadingButtons, openDispute: true });
submitAction({ action: 'dispute' });
};
const confirmFiatReceived = function () {
setLoadingButtons({ ...noLoadingButtons, fiatReceived: true });
submitAction({ action: 'confirm' });
};
const confirmFiatSent = function () {
setLoadingButtons({ ...noLoadingButtons, fiatSent: true });
submitAction({ action: 'confirm' });
};
const updateInvoice = function (invoice: string) {
setLoadingButtons({ ...noLoadingButtons, submitInvoice: true });
submitAction({ action: 'update_invoice', invoice });
};
const updateAddress = function () {
setLoadingButtons({ ...noLoadingButtons, submitAddress: true });
submitAction({
action: 'update_address',
address: onchain.address,
mining_fee_rate: onchain.miningFee,
});
};
const pauseOrder = function () {
setLoadingButtons({ ...noLoadingButtons, pauseOrder: true });
submitAction({ action: 'pause' });
};
const submitStatement = function () {
let statement = dispute.statement;
if (dispute.attachLogs) {
const payload = { statement, messages, token: systemClient.getItem('robot_token') };
statement = JSON.stringify(payload, null, 2);
}
setLoadingButtons({ ...noLoadingButtons, submitStatement: true });
submitAction({ action: 'submit_statement', statement });
};
const ratePlatform = function (rating: number) {
submitAction({ action: 'rate_platform', rating });
};
const handleWebln = async (order: Order) => {
const webln = await getWebln().catch(() => console.log('WebLN not available'));
// If Webln implements locked payments compatibility, this logic might be simplier
if (webln == undefined) {
return null;
} else if (order.is_maker && order.status == 0) {
webln.sendPayment(order.bond_invoice);
setWaitingWebln(true);
setOpen({ ...open, webln: true });
} else if (order.is_taker && order.status == 3) {
webln.sendPayment(order.bond_invoice);
setWaitingWebln(true);
setOpen({ ...open, webln: true });
} else if (order.is_seller && (order.status == 6 || order.status == 7)) {
webln.sendPayment(order.escrow_invoice);
setWaitingWebln(true);
setOpen({ ...open, webln: true });
} else if (order.is_buyer && (order.status == 6 || order.status == 8)) {
setWaitingWebln(true);
setOpen({ ...open, webln: true });
webln
.makeInvoice(order.trade_satoshis)
.then((invoice: any) => {
if (invoice) {
updateInvoice(invoice.paymentRequest);
setWaitingWebln(false);
setOpen(closeAll);
}
})
.catch(() => {
setWaitingWebln(false);
setOpen(closeAll);
});
} else {
setWaitingWebln(false);
}
};
// Effect on Order Status change (used for WebLN)
useEffect(() => {
if (order.status != lastOrderStatus) {
setLastOrderStatus(order.status);
handleWebln(order);
}
}, [order.status]);
const statusToContract = function (order: Order) {
const status = order.status;
const isBuyer = order.is_buyer;
const isMaker = order.is_maker;
let title: string = 'Unknown Order Status';
let titleVariables: object = {};
let titleColor: string = 'primary';
let titleIcon: () => JSX.Element = function () {
return <></>;
};
let prompt = () => <span>Wops!</span>;
let bondStatus: 'hide' | 'locked' | 'unlocked' | 'settled' = 'hide';
// 0: 'Waiting for maker bond'
if (status == 0) {
if (isMaker) {
title = 'Lock {{amountSats}} Sats to PUBLISH order';
titleVariables = { amountSats: pn(order.bond_satoshis) };
prompt = () => {
return <LockInvoicePrompt order={order} concept={'bond'} />;
};
bondStatus = 'hide';
}
// 1: 'Public'
} else if (status == 1) {
if (isMaker) {
title = 'Your order is public';
prompt = () => {
return (
<PublicWaitPrompt
order={order}
pauseLoading={loadingButtons.pauseOrder}
onClickPauseOrder={pauseOrder}
/>
);
};
bondStatus = 'locked';
}
// 2: 'Paused'
} else if (status == 2) {
if (isMaker) {
title = 'Your order is paused';
prompt = () => {
return (
<PausedPrompt
pauseLoading={loadingButtons.pauseOrder}
onClickResumeOrder={pauseOrder}
/>
);
};
bondStatus = 'locked';
}
// 3: 'Waiting for taker bond'
} else if (status == 3) {
if (isMaker) {
title = 'A taker has been found!';
prompt = () => {
return <TakerFoundPrompt />;
};
bondStatus = 'locked';
} else {
title = 'Lock {{amountSats}} Sats to TAKE order';
titleVariables = { amountSats: pn(order.bond_satoshis) };
prompt = () => {
return <LockInvoicePrompt order={order} concept={'bond'} />;
};
bondStatus = 'hide';
}
// 5: 'Expired'
} else if (status == 5) {
title = 'The order has expired';
prompt = () => {
return (
<ExpiredPrompt
loadingRenew={loadingButtons.renewOrder}
order={order}
onClickRenew={() => {
onRenewOrder();
setLoadingButtons({ ...noLoadingButtons, renewOrder: true });
}}
/>
);
};
bondStatus = 'hide'; // To do: show bond status according to expiry message.
// 6: 'Waiting for trade collateral and buyer invoice'
} else if (status == 6) {
bondStatus = 'locked';
if (isBuyer) {
title = 'Submit payout info for {{amountSats}} Sats';
titleVariables = { amountSats: pn(order.invoice_amount) };
prompt = function () {
return (
<PayoutPrompt
order={order}
onClickSubmitInvoice={updateInvoice}
loadingLightning={loadingButtons.submitInvoice}
lightning={lightning}
setLightning={setLightning}
onClickSubmitAddress={updateAddress}
loadingOnchain={loadingButtons.submitAddress}
onchain={onchain}
setOnchain={setOnchain}
/>
);
};
} else {
title = 'Lock {{amountSats}} Sats as collateral';
titleVariables = { amountSats: pn(order.escrow_satoshis) };
titleColor = 'warning';
prompt = () => {
return <LockInvoicePrompt order={order} concept={'escrow'} />;
};
}
// 7: 'Waiting only for seller trade collateral'
} else if (status == 7) {
bondStatus = 'locked';
if (isBuyer) {
title = 'Your info looks good!';
prompt = () => {
return <PayoutWaitPrompt />;
};
} else {
title = 'Lock {{amountSats}} Sats as collateral';
titleVariables = { amountSats: pn(order.escrow_satoshis) };
titleColor = 'warning';
prompt = () => {
return <LockInvoicePrompt order={order} concept={'escrow'} />;
};
}
// 8: 'Waiting only for buyer invoice'
} else if (status == 8) {
bondStatus = 'locked';
if (isBuyer) {
title = 'Submit payout info for {{amountSats}} Sats';
titleVariables = { amountSats: pn(order.invoice_amount) };
prompt = () => {
return (
<PayoutPrompt
order={order}
onClickSubmitInvoice={updateInvoice}
loadingLightning={loadingButtons.submitInvoice}
lightning={lightning}
setLightning={setLightning}
onClickSubmitAddress={updateAddress}
loadingOnchain={loadingButtons.submitAddress}
onchain={onchain}
setOnchain={setOnchain}
/>
);
};
} else {
title = 'The trade collateral is locked!';
prompt = () => {
return <EscrowWaitPrompt />;
};
}
// 9: 'Sending fiat - In chatroom'
// 10: 'Fiat sent - In chatroom'
} else if (status == 9 || status == 10) {
title = isBuyer ? 'Chat with the seller' : 'Chat with the buyer';
prompt = function () {
return (
<ChatPrompt
order={order}
onClickConfirmSent={confirmFiatSent}
onClickConfirmReceived={() => setOpen({ ...open, confirmFiatReceived: true })}
loadingSent={loadingButtons.fiatSent}
loadingReceived={loadingButtons.fiatReceived}
onClickDispute={() => setOpen({ ...open, confirmDispute: true })}
loadingDispute={loadingButtons.openDispute}
baseUrl={baseUrl}
messages={messages}
setMessages={setMessages}
/>
);
};
bondStatus = 'locked';
// 11: 'In dispute'
} else if (status == 11) {
bondStatus = 'settled';
if (order.statement_submitted) {
title = 'We have received your statement';
prompt = function () {
return <DisputeWaitPeerPrompt />;
};
} else {
title = 'A dispute has been opened';
prompt = function () {
return (
<DisputePrompt
loading={loadingButtons.submitStatement}
dispute={dispute}
setDispute={setDispute}
onClickSubmit={submitStatement}
/>
);
};
}
// 12: 'Collaboratively cancelled'
} else if (status == 12) {
// 13: 'Sending satoshis to buyer'
} else if (status == 13) {
if (isBuyer) {
bondStatus = 'unlocked';
title = 'Attempting Lightning Payment';
prompt = function () {
return <SendingSatsPrompt />;
};
} else {
title = 'Trade finished!';
titleColor = 'success';
titleIcon = function () {
return <Bolt xs={{ width: '1em', height: '1em' }} color='warning' />;
};
prompt = function () {
return (
<SuccessfulPrompt
baseUrl={baseUrl}
order={order}
ratePlatform={ratePlatform}
onClickStartAgain={onStartAgain}
loadingRenew={loadingButtons.renewOrder}
onClickRenew={() => {
onRenewOrder();
setLoadingButtons({ ...noLoadingButtons, renewOrder: true });
}}
/>
);
};
}
// 14: 'Sucessful trade'
} else if (status == 14) {
title = 'Trade finished!';
titleColor = 'success';
titleIcon = function () {
return <Bolt xs={{ width: '1em', height: '1em' }} color='warning' />;
};
prompt = function () {
return (
<SuccessfulPrompt
baseUrl={baseUrl}
order={order}
ratePlatform={ratePlatform}
onClickStartAgain={onStartAgain}
loadingRenew={loadingButtons.renewOrder}
onClickRenew={() => {
onRenewOrder();
setLoadingButtons({ ...noLoadingButtons, renewOrder: true });
}}
/>
);
};
// 15: 'Failed lightning network routing'
} else if (status == 15) {
if (isBuyer) {
bondStatus = 'unlocked';
title = 'Lightning Routing Failed';
prompt = function () {
return (
<RoutingFailedPrompt
order={order}
onClickSubmitInvoice={updateInvoice}
loadingLightning={loadingButtons.submitInvoice}
lightning={lightning}
setLightning={setLightning}
/>
);
};
} else {
title = 'Trade finished!';
titleColor = 'success';
titleIcon = function () {
return <Bolt xs={{ width: '1em', height: '1em' }} color='warning' />;
};
prompt = function () {
return (
<SuccessfulPrompt
baseUrl={baseUrl}
order={order}
ratePlatform={ratePlatform}
onClickStartAgain={onStartAgain}
loadingRenew={loadingButtons.renewOrder}
onClickRenew={() => {
onRenewOrder();
setLoadingButtons({ ...noLoadingButtons, renewOrder: true });
}}
/>
);
};
}
// 16: 'Wait for dispute resolution'
} else if (status == 16) {
bondStatus = 'settled';
title = 'We have the statements';
prompt = function () {
return <DisputeWaitResolutionPrompt />;
};
// 17: 'Maker lost dispute'
// 18: 'Taker lost dispute'
} else if ((status == 17 && isMaker) || (status == 18 && !isMaker)) {
title = 'You have won the dispute';
prompt = function () {
return <DisputeWinnerPrompt />;
};
} else if ((status == 17 && !isMaker) || (status == 18 && isMaker)) {
title = 'You have lost the dispute';
prompt = function () {
return <DisputeLoserPrompt />;
};
bondStatus = 'settled';
}
return { title, titleVariables, titleColor, prompt, bondStatus, titleIcon };
};
const contract = statusToContract(order);
return (
<Box>
<WebLNDialog
open={open.webln}
onClose={() => setOpen(closeAll)}
waitingWebln={waitingWebln}
isBuyer={order.is_buyer}
/>
<ConfirmDisputeDialog
open={open.confirmDispute}
onClose={() => setOpen(closeAll)}
onAgreeClick={openDispute}
/>
<ConfirmCancelDialog
open={open.confirmCancel}
onClose={() => setOpen(closeAll)}
onCancelClick={cancel}
/>
<ConfirmCollabCancelDialog
open={open.confirmCollabCancel}
onClose={() => setOpen(closeAll)}
onCollabCancelClick={cancel}
loading={loadingButtons.cancel}
peerAskedCancel={order.pending_cancel}
/>
<ConfirmFiatReceivedDialog
open={open.confirmFiatReceived}
order={order}
loadingButton={loadingButtons.fiatReceived}
onClose={() => setOpen(closeAll)}
onConfirmClick={confirmFiatReceived}
/>
<CollabCancelAlert order={order} />
<Grid
container
padding={1}
direction='column'
justifyContent='flex-start'
alignItems='center'
spacing={0}
>
<Grid item>
<Title
order={order}
text={contract.title}
color={contract.titleColor}
icon={contract.titleIcon}
variables={contract.titleVariables}
/>
</Grid>
<Divider />
<Grid item>{contract.prompt()}</Grid>
{contract.bondStatus != 'hide' ? (
<Grid item sx={{ width: '100%' }}>
<Divider />
<BondStatus status={contract.bondStatus} isMaker={order.is_maker} />
</Grid>
) : (
<></>
)}
<Grid item>
<CancelButton
order={order}
onClickCancel={cancel}
openCancelDialog={() => setOpen({ ...closeAll, confirmCancel: true })}
openCollabCancelDialog={() => setOpen({ ...closeAll, confirmCollabCancel: true })}
loading={loadingButtons.cancel}
/>
</Grid>
</Grid>
</Box>
);
};
export default TradeBox;

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
import packageJson from '../../package.json';
export interface Info {
num_public_buy_orders: number;
num_public_sell_orders: number;
@ -23,8 +25,6 @@ export interface Info {
openUpdateClient: boolean;
loading: boolean;
}
import packageJson from '../../package.json';
const semver = packageJson.version.split('.');
export const defaultInfo: Info = {

View File

@ -14,6 +14,7 @@ export interface TradeRobotSummary {
export interface TradeCoordinatorSummary {
contract_timestamp: Date;
contract_total_time: number;
contract_exchange_rate: number;
routing_fee_sats: number;
trade_revenue_sats: number;
}
@ -38,13 +39,14 @@ export interface Order {
taker: number;
escrow_duration: number;
total_secs_exp: number;
penalty: Date;
penalty: Date | undefined;
is_maker: boolean;
is_taker: boolean;
is_participant: boolean;
maker_status: 'Active' | 'Seen recently' | 'Inactive';
taker_status: 'Active' | 'Seen recently' | 'Inactive';
price_now: number;
price_now: number | undefined;
premium_now: number | undefined;
premium_percentile: number;
num_similar_orders: number;
tg_enabled: boolean; // deprecated

View File

@ -7,6 +7,7 @@ class Settings extends BaseSettings {
const fontSizeCookie = systemClient.getItem('settings_fontsize_basic');
this.fontSize = fontSizeCookie !== '' ? Number(fontSizeCookie) : 14;
}
public frontend: 'basic' | 'pro' = 'basic';
}

View File

@ -7,6 +7,7 @@ class Settings extends BaseSettings {
const fontSizeCookie = systemClient.getItem('settings_fontsize_pro');
this.fontSize = fontSizeCookie !== '' ? Number(fontSizeCookie) : 12;
}
public frontend: 'basic' | 'pro' = 'pro';
}

View File

@ -18,7 +18,7 @@ const ToolBar = ({ height = '3em', settings, setSettings }: ToolBarProps): JSX.E
elevation={6}
sx={{
width: `100%`,
height: height,
height,
textAlign: 'center',
padding: '1em',
borderRadius: 0,

View File

@ -51,9 +51,11 @@ class SystemNativeClient implements SystemClient {
public getItem: (key: string) => string = (key) => {
return this.getCookie(key);
};
public setItem: (key: string, value: string) => void = (key, value) => {
this.setCookie(key, value);
};
public deleteItem: (key: string) => void = (key) => {
this.deleteCookie(key);
};

View File

@ -27,7 +27,7 @@ class SystemWebClient implements SystemClient {
}
};
//Cookies
// Cookies
public getCookie: (key: string) => string = (key) => {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
@ -44,9 +44,11 @@ class SystemWebClient implements SystemClient {
return cookieValue || '';
};
public setCookie: (key: string, value: string) => void = (key, value) => {
document.cookie = `${key}=${value};path=/;SameSite=Strict`;
};
public deleteCookie: (key: string) => void = (key) => {
document.cookie = `${name}= ; expires = Thu, 01 Jan 1970 00:00:00 GMT`;
};
@ -56,9 +58,11 @@ class SystemWebClient implements SystemClient {
const value = window.localStorage.getItem(key);
return value || '';
};
public setItem: (key: string, value: string) => void = (key, value) => {
window.localStorage.setItem(key, value);
};
public deleteItem: (key: string) => void = (key) => {
window.localStorage.removeItem(key);
};

View File

@ -17,6 +17,10 @@ class WebsocketConnectionWeb implements WebsocketConnection {
);
};
public close: () => void = () => {
this.rws.close();
};
public onMessage: (event: (message: any) => void) => void = (event) => {
this.rws.addEventListener('message', event);
};

View File

@ -5,6 +5,7 @@ export interface WebsocketConnection {
onMessage: (event: (message: any) => void) => void;
onClose: (event: () => void) => void;
onError: (event: () => void) => void;
close: () => void;
}
export interface WebsocketClient {

View File

@ -19,6 +19,7 @@ class ApiWebClient implements ApiClient {
headers: this.getHeaders(),
body: JSON.stringify(body),
};
return await fetch(baseUrl + path, requestOptions).then(
async (response) => await response.json(),
);

View File

@ -4,7 +4,6 @@
*/
const saveAsJson = (filename, dataObjToWrite) => {
console.log(filename, dataObjToWrite);
const blob = new Blob([JSON.stringify(dataObjToWrite, null, 2)], { type: 'text/json' });
const link = document.createElement('a');

View File

@ -349,7 +349,7 @@
"Messages": "Messages",
"Verified signature by {{nickname}}": "Verified signature by {{nickname}}",
"Cannot verify signature of {{nickname}}": "Cannot verify signature of {{nickname}}",
"Activate turtle mode (Use it when the connection is slow)": "Activate turtle mode (Use it when the connection is slow)",
"Activate slow mode (use it when the connection is slow)": "Activate slow mode (use it when the connection is slow)",
"CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline",
"Contract Box": "Contract Box",
"Robots show commitment to their peers": "Robots show commitment to their peers",

View File

@ -338,7 +338,7 @@
"Messages": "Mensajes",
"Verified signature by {{nickname}}": "Firma de {{nickname}} verificada",
"Cannot verify signature of {{nickname}}": "No se pudo verificar la firma de {{nickname}}",
"Activate turtle mode (Use it when the connection is slow)": "Activar modo tortuga (Úsalo cuando tu conexión es lenta)",
"Activate slow mode (use it when the connection is slow)": "Activar modo lento (Úsalo cuando tu conexión es inestable)",
"CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline",
"Contract Box": "Contrato",
"Robots show commitment to their peers": "Los Robots deben mostrar su compromiso",