Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
@ -0,0 +1,5 @@ |
|||
node_modules |
|||
.DS_Store |
|||
dist-ssr |
|||
*.local |
|||
dist/ |
@ -0,0 +1,13 @@ |
|||
<!DOCTYPE html> |
|||
<html lang="en"> |
|||
<head> |
|||
<meta charset="UTF-8" /> |
|||
<link rel="icon" type="image/svg+xml" href="/src/logo.svg" /> |
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|||
<title>Itchy Stats</title> |
|||
</head> |
|||
<body> |
|||
<div id="root"></div> |
|||
<script type="module" src="/src/main.tsx"></script> |
|||
</body> |
|||
</html> |
@ -0,0 +1,67 @@ |
|||
{ |
|||
"name": "itchysats", |
|||
"version": "0.1.0", |
|||
"scripts": { |
|||
"dev": "vite", |
|||
"build": "vite build", |
|||
"serve": "vite preview", |
|||
"eslint": "eslint src/**/*.{ts,tsx}", |
|||
"tsc": "tsc" |
|||
}, |
|||
"dependencies": { |
|||
"@chakra-ui/icons": "^1.0.16", |
|||
"@chakra-ui/react": "^1.6.10", |
|||
"@emotion/react": "^11", |
|||
"@emotion/styled": "^11", |
|||
"react": "^17.0.2", |
|||
"react-dom": "^17.0.2", |
|||
"@testing-library/jest-dom": "^5.9.0", |
|||
"@testing-library/react": "^10.2.1", |
|||
"@testing-library/user-event": "^12.0.2", |
|||
"@types/jest": "^25.0.0", |
|||
"@types/node": "^12.0.0", |
|||
"@types/react": "^17.0.34", |
|||
"@types/react-dom": "^17.0.11", |
|||
"@types/react-router-dom": "5.3.1", |
|||
"framer-motion": "^4", |
|||
"react-async": "^10.0.1", |
|||
"react-icons": "^3.0.0", |
|||
"react-refresh": "^0.10.0", |
|||
"react-router-dom": "5.3.0", |
|||
"react-scripts": "4.0.3", |
|||
"react-sse-hooks": "^1.0.5", |
|||
"web-vitals": "^0.2.2", |
|||
"@vitejs/plugin-react": "^1.0.0", |
|||
"typescript": "^4.4.4", |
|||
"vite": "^2.6.13" |
|||
}, |
|||
"devDependencies": { |
|||
"@types/eslint": "^7", |
|||
"@types/react": "^17.0.34", |
|||
"@types/react-dom": "^17.0.11", |
|||
"typescript": "^4.4.4", |
|||
"vite": "^2.6.13" |
|||
}, |
|||
"eslintConfig": { |
|||
"parser": "@typescript-eslint/parser", |
|||
"plugins": [ |
|||
"@typescript-eslint" |
|||
], |
|||
"parserOptions": { |
|||
"project": "./tsconfig.json" |
|||
}, |
|||
"extends": [ |
|||
"react-app", |
|||
"react-app/jest" |
|||
], |
|||
"rules": { |
|||
"@typescript-eslint/no-floating-promises": "error", |
|||
"@typescript-eslint/promise-function-async": "error", |
|||
"require-await": "off", |
|||
"@typescript-eslint/require-await": "error", |
|||
"@typescript-eslint/no-use-before-define": "off", |
|||
"@typescript-eslint/no-unused-vars": "error", |
|||
"react-hooks/exhaustive-deps": "error" |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,168 @@ |
|||
import { Box, StackDivider, useToast, VStack } from "@chakra-ui/react"; |
|||
import * as React from "react"; |
|||
import { useEffect, useState } from "react"; |
|||
import { useAsync } from "react-async"; |
|||
import { Route, Switch } from "react-router-dom"; |
|||
import { useEventSource } from "react-sse-hooks"; |
|||
import History from "./components/History"; |
|||
import Nav from "./components/NavBar"; |
|||
import Trade from "./components/Trade"; |
|||
import { |
|||
Cfd, |
|||
CfdOrderRequestPayload, |
|||
intoCfd, |
|||
intoOrder, |
|||
MarginRequestPayload, |
|||
MarginResponse, |
|||
Order, |
|||
StateGroupKey, |
|||
WalletInfo, |
|||
} from "./components/Types"; |
|||
import { Wallet } from "./components/Wallet"; |
|||
import useLatestEvent from "./Hooks"; |
|||
|
|||
async function getMargin(payload: MarginRequestPayload): Promise<MarginResponse> { |
|||
let res = await fetch(`/api/calculate/margin`, { method: "POST", body: JSON.stringify(payload) }); |
|||
|
|||
if (!res.status.toString().startsWith("2")) { |
|||
throw new Error("failed to create new CFD order request: " + res.status + ", " + res.statusText); |
|||
} |
|||
|
|||
return res.json(); |
|||
} |
|||
|
|||
async function postCfdOrderRequest(payload: CfdOrderRequestPayload) { |
|||
let res = await fetch(`/api/cfd/order`, { method: "POST", body: JSON.stringify(payload) }); |
|||
if (!res.status.toString().startsWith("2")) { |
|||
console.log(`Error${JSON.stringify(res)}`); |
|||
throw new Error("failed to create new CFD order request: " + res.status + ", " + res.statusText); |
|||
} |
|||
} |
|||
|
|||
export const App = () => { |
|||
const toast = useToast(); |
|||
|
|||
let source = useEventSource({ source: "/api/feed" }); |
|||
const walletInfo = useLatestEvent<WalletInfo>(source, "wallet"); |
|||
const order = useLatestEvent<Order>(source, "order", intoOrder); |
|||
const cfdsOrUndefined = useLatestEvent<Cfd[]>(source, "cfds", intoCfd); |
|||
let cfds = cfdsOrUndefined ? cfdsOrUndefined! : []; |
|||
cfds.sort((a, b) => a.order_id.localeCompare(b.order_id)); |
|||
|
|||
let [quantity, setQuantity] = useState("0"); |
|||
let [margin, setMargin] = useState("0"); |
|||
let [userHasEdited, setUserHasEdited] = useState(false); |
|||
|
|||
const { price, min_quantity, max_quantity, leverage, liquidation_price: liquidationPrice } = order || {}; |
|||
|
|||
let effectiveQuantity = userHasEdited ? quantity : (min_quantity?.toString() || "0"); |
|||
|
|||
let { run: calculateMargin } = useAsync({ |
|||
deferFn: async ([payload]: any[]) => { |
|||
try { |
|||
let res = await getMargin(payload as MarginRequestPayload); |
|||
setMargin(res.margin.toString()); |
|||
} catch (e) { |
|||
const description = typeof e === "string" ? e : JSON.stringify(e); |
|||
|
|||
toast({ |
|||
title: "Error", |
|||
description, |
|||
status: "error", |
|||
duration: 9000, |
|||
isClosable: true, |
|||
}); |
|||
} |
|||
}, |
|||
}); |
|||
|
|||
let { run: makeNewOrderRequest, isLoading: isCreatingNewOrderRequest } = useAsync({ |
|||
deferFn: async ([payload]: any[]) => { |
|||
try { |
|||
await postCfdOrderRequest(payload as CfdOrderRequestPayload); |
|||
} catch (e) { |
|||
console.error(`Error received: ${JSON.stringify(e)}`); |
|||
const description = typeof e === "string" ? e : JSON.stringify(e); |
|||
|
|||
toast({ |
|||
title: "Error", |
|||
description, |
|||
status: "error", |
|||
duration: 9000, |
|||
isClosable: true, |
|||
}); |
|||
} |
|||
}, |
|||
}); |
|||
|
|||
useEffect(() => { |
|||
if (!order) { |
|||
return; |
|||
} |
|||
let quantity = effectiveQuantity ? Number.parseFloat(effectiveQuantity) : 0; |
|||
let payload: MarginRequestPayload = { |
|||
leverage: order.leverage, |
|||
price: order.price, |
|||
quantity, |
|||
}; |
|||
calculateMargin(payload); |
|||
}, // Eslint demands us to include `calculateMargin` in the list of dependencies.
|
|||
// We don't want that as we will end up in an endless loop. It is safe to ignore `calculateMargin` because
|
|||
// nothing in `calculateMargin` depends on outside values, i.e. is guaranteed to be stable.
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|||
[margin, effectiveQuantity, order]); |
|||
|
|||
const format = (val: any) => `$` + val; |
|||
const parse = (val: any) => val.replace(/^\$/, ""); |
|||
|
|||
return ( |
|||
<> |
|||
<Nav walletInfo={walletInfo} /> |
|||
<Box textAlign="center" padding={3}> |
|||
<Switch> |
|||
<Route path="/"> |
|||
<VStack divider={<StackDivider borderColor="gray.500" />} spacing={4}> |
|||
<Trade |
|||
order_id={order?.id} |
|||
quantity={format(effectiveQuantity)} |
|||
max_quantity={max_quantity || 0} |
|||
min_quantity={min_quantity || 0} |
|||
price={price} |
|||
margin={margin} |
|||
leverage={leverage} |
|||
liquidationPrice={liquidationPrice} |
|||
onQuantityChange={(valueString: string) => { |
|||
setUserHasEdited(true); |
|||
setQuantity(parse(valueString)); |
|||
if (!order) { |
|||
return; |
|||
} |
|||
let quantity = valueString ? Number.parseFloat(valueString) : 0; |
|||
let payload: MarginRequestPayload = { |
|||
leverage: order.leverage, |
|||
price: order.price, |
|||
quantity, |
|||
}; |
|||
calculateMargin(payload); |
|||
}} |
|||
onLongSubmit={makeNewOrderRequest} |
|||
isSubmitting={isCreatingNewOrderRequest} |
|||
/> |
|||
<History |
|||
cfds={cfds.filter((cfd) => cfd.state.getGroup() !== StateGroupKey.CLOSED)} |
|||
title={"Open Positions"} |
|||
/> |
|||
<History |
|||
cfds={cfds.filter((cfd) => cfd.state.getGroup() === StateGroupKey.CLOSED)} |
|||
title={"Closed Positions"} |
|||
/> |
|||
</VStack> |
|||
</Route> |
|||
<Route path="/wallet"> |
|||
<Wallet walletInfo={walletInfo} /> |
|||
</Route> |
|||
</Switch> |
|||
</Box> |
|||
</> |
|||
); |
|||
}; |
@ -0,0 +1,27 @@ |
|||
import { useState } from "react"; |
|||
import { useEventSourceListener } from "react-sse-hooks"; |
|||
|
|||
export default function useLatestEvent<T>( |
|||
source: EventSource, |
|||
event_name: string, |
|||
mapping: (key: string, value: any) => any = (key, value) => value, |
|||
): T | null { |
|||
const [state, setState] = useState<T | null>(null); |
|||
|
|||
useEventSourceListener<T | null>( |
|||
{ |
|||
source: source, |
|||
startOnInit: true, |
|||
event: { |
|||
name: event_name, |
|||
listener: ({ event }) => { |
|||
// @ts-ignore - yes, there is a data field on event
|
|||
setState(JSON.parse(event.data, mapping)); |
|||
}, |
|||
}, |
|||
}, |
|||
[source], |
|||
); |
|||
|
|||
return state; |
|||
} |
@ -0,0 +1,25 @@ |
|||
import { IconButton, IconButtonProps, useColorMode, useColorModeValue } from "@chakra-ui/react"; |
|||
import * as React from "react"; |
|||
import { FaMoon, FaSun } from "react-icons/fa"; |
|||
|
|||
type ColorModeSwitcherProps = Omit<IconButtonProps, "aria-label">; |
|||
|
|||
export const ColorModeSwitcher: React.FC<ColorModeSwitcherProps> = (props) => { |
|||
const { toggleColorMode } = useColorMode(); |
|||
const text = useColorModeValue("dark", "light"); |
|||
const SwitchIcon = useColorModeValue(FaMoon, FaSun); |
|||
|
|||
return ( |
|||
<IconButton |
|||
size="md" |
|||
fontSize="lg" |
|||
variant="ghost" |
|||
color="current" |
|||
marginLeft="2" |
|||
onClick={toggleColorMode} |
|||
icon={<SwitchIcon />} |
|||
aria-label={`Switch to ${text} mode`} |
|||
{...props} |
|||
/> |
|||
); |
|||
}; |
@ -0,0 +1,264 @@ |
|||
import { ExternalLinkIcon, Icon } from "@chakra-ui/icons"; |
|||
import { |
|||
Badge, |
|||
Box, |
|||
Button, |
|||
Center, |
|||
Checkbox, |
|||
Divider, |
|||
GridItem, |
|||
Heading, |
|||
HStack, |
|||
Link, |
|||
Popover, |
|||
PopoverArrow, |
|||
PopoverBody, |
|||
PopoverCloseButton, |
|||
PopoverContent, |
|||
PopoverFooter, |
|||
PopoverHeader, |
|||
PopoverTrigger, |
|||
SimpleGrid, |
|||
Spinner, |
|||
Table, |
|||
Tbody, |
|||
Td, |
|||
Text, |
|||
Tr, |
|||
useColorModeValue, |
|||
useToast, |
|||
VStack, |
|||
} from "@chakra-ui/react"; |
|||
import * as React from "react"; |
|||
import { useAsync } from "react-async"; |
|||
import { Cfd, StateGroupKey, StateKey, Tx, TxLabel } from "./Types"; |
|||
|
|||
interface HistoryProps { |
|||
cfds: Cfd[]; |
|||
title: string; |
|||
} |
|||
|
|||
const History = ({ cfds, title }: HistoryProps) => { |
|||
return ( |
|||
<VStack spacing={3}> |
|||
<Heading size={"lg"} padding={2}>{title}</Heading> |
|||
<SimpleGrid |
|||
columns={{ sm: 2, md: 4 }} |
|||
gap={4} |
|||
> |
|||
{cfds.map((cfd) => { |
|||
return (<GridItem rowSpan={1} colSpan={2} key={cfd.order_id}> |
|||
<CfdDetails cfd={cfd} /> |
|||
</GridItem>); |
|||
})} |
|||
</SimpleGrid> |
|||
</VStack> |
|||
); |
|||
}; |
|||
|
|||
export default History; |
|||
|
|||
interface CfdDetailsProps { |
|||
cfd: Cfd; |
|||
} |
|||
|
|||
async function doPostAction(id: string, action: string) { |
|||
await fetch( |
|||
`/api/cfd/${id}/${action}`, |
|||
{ method: "POST", credentials: "include" }, |
|||
); |
|||
} |
|||
|
|||
const CfdDetails = ({ cfd }: CfdDetailsProps) => { |
|||
const toast = useToast(); |
|||
const initialPrice = `$${cfd.initial_price.toLocaleString()}`; |
|||
const quantity = `$${cfd.quantity_usd}`; |
|||
const margin = `₿${Math.round((cfd.margin) * 1_000_000) / 1_000_000}`; |
|||
const liquidationPrice = `$${cfd.liquidation_price}`; |
|||
const pAndL = Math.round((cfd.profit_btc) * 1_000_000) / 1_000_000; |
|||
const expiry = cfd.expiry_timestamp; |
|||
const profit = Math.round((cfd.margin + cfd.profit_btc) * 1_000_000) / 1_000_000; |
|||
|
|||
const txLock = cfd.details.tx_url_list.find((tx) => tx.label === TxLabel.Lock); |
|||
const txCommit = cfd.details.tx_url_list.find((tx) => tx.label === TxLabel.Commit); |
|||
const txRefund = cfd.details.tx_url_list.find((tx) => tx.label === TxLabel.Refund); |
|||
const txCet = cfd.details.tx_url_list.find((tx) => tx.label === TxLabel.Cet); |
|||
const txSettled = cfd.details.tx_url_list.find((tx) => tx.label === TxLabel.Collaborative); |
|||
|
|||
let { run: postAction, isLoading: isActioning } = useAsync({ |
|||
deferFn: async ([orderId, action]: any[]) => { |
|||
try { |
|||
console.log(`Closing: ${orderId} ${action}`); |
|||
await doPostAction(orderId, action); |
|||
} catch (e) { |
|||
const description = typeof e === "string" ? e : JSON.stringify(e); |
|||
|
|||
toast({ |
|||
title: "Error", |
|||
description, |
|||
status: "error", |
|||
duration: 9000, |
|||
isClosable: true, |
|||
}); |
|||
} |
|||
}, |
|||
}); |
|||
|
|||
const disableCloseButton = cfd.state.getGroup() === StateGroupKey.CLOSED |
|||
|| [StateKey.OPEN_COMMITTED, StateKey.OUTGOING_SETTLEMENT_PROPOSAL, StateKey.PENDING_CLOSE].includes( |
|||
cfd.state.key, |
|||
); |
|||
|
|||
return ( |
|||
<HStack bg={useColorModeValue("gray.100", "gray.700")} rounded={5}> |
|||
<Center rounded={5} h={"100%"}> |
|||
<Table variant="striped" colorScheme="gray" size="sm"> |
|||
<Tbody> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Quantity</Text></Td> |
|||
<Td>{quantity}</Td> |
|||
</Tr> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Opening price</Text></Td> |
|||
<Td>{initialPrice}</Td> |
|||
</Tr> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Liquidation</Text></Td> |
|||
<Td>{liquidationPrice}</Td> |
|||
</Tr> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Margin</Text></Td> |
|||
<Td>{margin}</Td> |
|||
</Tr> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Unrealized P/L</Text></Td> |
|||
<Td>{pAndL.toString()}</Td> |
|||
</Tr> |
|||
</Tbody> |
|||
</Table> |
|||
</Center> |
|||
<VStack> |
|||
<Badge colorScheme={cfd.state.getColorScheme()}>{cfd.state.getLabel()}</Badge> |
|||
<HStack w={"95%"}> |
|||
<VStack> |
|||
<TxIcon tx={txLock} /> |
|||
<Text>Lock</Text> |
|||
</VStack> |
|||
{txSettled |
|||
? <> |
|||
<Divider variant={txSettled ? "solid" : "dashed"} /> |
|||
<VStack> |
|||
<TxIcon tx={txSettled} /> |
|||
<Text>Payout</Text> |
|||
</VStack> |
|||
</> |
|||
: <> |
|||
<Divider variant={txCommit ? "solid" : "dashed"} /> |
|||
<VStack> |
|||
<TxIcon tx={txCommit} /> |
|||
<Text>Commit</Text> |
|||
</VStack> |
|||
|
|||
{txRefund |
|||
? <> |
|||
<Divider variant={txRefund ? "solid" : "dashed"} /> |
|||
<VStack> |
|||
<TxIcon tx={txRefund} /> |
|||
<Text>Refund</Text> |
|||
</VStack> |
|||
</> |
|||
: <> |
|||
<Divider variant={txCet ? "solid" : "dashed"} /> |
|||
<VStack> |
|||
<TxIcon tx={txCet} /> |
|||
<Text>Payout</Text> |
|||
</VStack> |
|||
</>} |
|||
</>} |
|||
</HStack> |
|||
<HStack> |
|||
<Box w={"45%"}> |
|||
<Text fontSize={"sm"} align={"left"}> |
|||
At the current rate you would receive <b>₿ {profit}</b> |
|||
</Text> |
|||
</Box> |
|||
<Box w={"45%"}> |
|||
<Popover |
|||
placement="bottom" |
|||
closeOnBlur={true} |
|||
> |
|||
{({ onClose }) => (<> |
|||
<PopoverTrigger> |
|||
<Button colorScheme={"blue"} disabled={disableCloseButton}>Close</Button> |
|||
</PopoverTrigger> |
|||
<PopoverContent color="white" bg="blue.800" borderColor="blue.800"> |
|||
<PopoverHeader pt={4} fontWeight="bold" border="0"> |
|||
Close your position |
|||
</PopoverHeader> |
|||
<PopoverArrow /> |
|||
<PopoverCloseButton /> |
|||
<PopoverBody> |
|||
<Text> |
|||
This will force-close your position if your counterparty cannot be reached. |
|||
The exchange rate at {expiry} |
|||
will determine your profit/losses. It is likely that the rate will change |
|||
until then. |
|||
</Text> |
|||
</PopoverBody> |
|||
<PopoverFooter |
|||
border="0" |
|||
d="flex" |
|||
alignItems="center" |
|||
justifyContent="space-between" |
|||
pb={4} |
|||
> |
|||
<Button |
|||
size="sm" |
|||
colorScheme="red" |
|||
onClick={async () => { |
|||
await postAction(cfd.order_id, "settle"); |
|||
onClose(); |
|||
}} |
|||
isLoading={isActioning} |
|||
> |
|||
Close |
|||
</Button> |
|||
<Checkbox defaultIsChecked>Don't show this again</Checkbox> |
|||
</PopoverFooter> |
|||
</PopoverContent> |
|||
</>)} |
|||
</Popover> |
|||
</Box> |
|||
</HStack> |
|||
</VStack> |
|||
</HStack> |
|||
); |
|||
}; |
|||
|
|||
const CircleIcon = (props: any) => ( |
|||
<Icon viewBox="0 0 200 200" {...props}> |
|||
<path |
|||
stroke="currentColor" |
|||
fill="transparent" |
|||
d="M 100, 100 m -75, 0 a 75,75 0 1,0 150,0 a 75,75 0 1,0 -150,0" |
|||
/> |
|||
</Icon> |
|||
); |
|||
|
|||
interface TxIconProps { |
|||
tx?: Tx; |
|||
} |
|||
|
|||
const TxIcon = ({ tx }: TxIconProps) => { |
|||
const iconColor = useColorModeValue("white.200", "white.600"); |
|||
|
|||
if (!tx) { |
|||
return (<CircleIcon boxSize={5} color={iconColor} />); |
|||
} else if (tx && !tx.url) { |
|||
return (<Spinner mx="2px" color={iconColor} speed="1.65s" />); |
|||
} else { |
|||
return (<Link href={tx.url!} isExternal> |
|||
<ExternalLinkIcon mx="2px" color={iconColor} /> |
|||
</Link>); |
|||
} |
|||
}; |
@ -0,0 +1,44 @@ |
|||
import { Box, BoxProps } from "@chakra-ui/layout"; |
|||
import { Center, Image, Text, VStack } from "@chakra-ui/react"; |
|||
import { motion } from "framer-motion"; |
|||
import React from "react"; |
|||
import { useHistory } from "react-router-dom"; |
|||
import logo from "../images/logo.svg"; |
|||
|
|||
const MotionBox = motion<BoxProps>(Box); |
|||
|
|||
export const Logo = () => { |
|||
const history = useHistory(); |
|||
|
|||
function handleClick() { |
|||
history.push("/trade"); |
|||
} |
|||
|
|||
return ( |
|||
<Center> |
|||
<VStack> |
|||
<MotionBox |
|||
height="200px" |
|||
width="200px" |
|||
whileHover={{ scale: 1.2 }} |
|||
whileTap={{ scale: 0.9 }} |
|||
animate={{ |
|||
scale: [1, 1.5, 1.5, 1, 1], |
|||
rotate: [0, 360], |
|||
}} |
|||
onClick={handleClick} |
|||
// @ts-ignore: lint is complaining but should be fine :)
|
|||
transition={{ |
|||
repeat: 999, |
|||
repeatType: "loop", |
|||
duration: 2, |
|||
}} |
|||
> |
|||
<Image src={logo} width="200px" /> |
|||
</MotionBox> |
|||
|
|||
<Text>Click to enter</Text> |
|||
</VStack> |
|||
</Center> |
|||
); |
|||
}; |
@ -0,0 +1,78 @@ |
|||
import { HamburgerIcon, MoonIcon, SunIcon } from "@chakra-ui/icons"; |
|||
import { |
|||
Box, |
|||
Button, |
|||
Flex, |
|||
Image, |
|||
Menu, |
|||
MenuButton, |
|||
MenuItem, |
|||
MenuList, |
|||
Stack, |
|||
useColorMode, |
|||
useColorModeValue, |
|||
} from "@chakra-ui/react"; |
|||
import * as React from "react"; |
|||
import { useHistory } from "react-router-dom"; |
|||
import logoBlack from "../images/logo_nav_bar_black.svg"; |
|||
import logoWhite from "../images/logo_nav_bar_white.svg"; |
|||
import { WalletInfo } from "./Types"; |
|||
import { WalletNavBar } from "./Wallet"; |
|||
|
|||
interface NavProps { |
|||
walletInfo: WalletInfo | null; |
|||
} |
|||
|
|||
export default function Nav({ walletInfo }: NavProps) { |
|||
const history = useHistory(); |
|||
|
|||
const { toggleColorMode } = useColorMode(); |
|||
const navBarLog = useColorModeValue( |
|||
<Image src={logoBlack} w="128px" />, |
|||
<Image src={logoWhite} w="128px" />, |
|||
); |
|||
|
|||
const toggleIcon = useColorModeValue( |
|||
<MoonIcon />, |
|||
<SunIcon />, |
|||
); |
|||
|
|||
return ( |
|||
<> |
|||
<Box bg={useColorModeValue("gray.100", "gray.900")} px={4}> |
|||
<Flex h={16} alignItems={"center"} justifyContent={"space-between"}> |
|||
<Menu> |
|||
<MenuButton |
|||
as={Button} |
|||
variant={"link"} |
|||
cursor={"pointer"} |
|||
minW={0} |
|||
> |
|||
<HamburgerIcon w={"32px"} /> |
|||
</MenuButton> |
|||
<MenuList alignItems={"center"}> |
|||
<MenuItem onClick={() => history.push("/trade")}>Home</MenuItem> |
|||
<MenuItem onClick={() => history.push("/wallet")}>Wallet</MenuItem> |
|||
<MenuItem>Settings</MenuItem> |
|||
</MenuList> |
|||
</Menu> |
|||
|
|||
<WalletNavBar walletInfo={walletInfo} /> |
|||
|
|||
<Flex alignItems={"center"}> |
|||
<Stack direction={"row"} spacing={7}> |
|||
<Button onClick={toggleColorMode} bg={"transparent"}> |
|||
{toggleIcon} |
|||
</Button> |
|||
<Box> |
|||
<Button bg={"transparent"} onClick={() => history.push("/trade")}> |
|||
{navBarLog} |
|||
</Button> |
|||
</Box> |
|||
</Stack> |
|||
</Flex> |
|||
</Flex> |
|||
</Box> |
|||
</> |
|||
); |
|||
} |
@ -0,0 +1,26 @@ |
|||
import { Text } from "@chakra-ui/react"; |
|||
import React from "react"; |
|||
import { unixTimestampToDate } from "./Types"; |
|||
|
|||
interface Props { |
|||
timestamp: number; |
|||
} |
|||
|
|||
export default function Timestamp( |
|||
{ |
|||
timestamp, |
|||
}: Props, |
|||
) { |
|||
return ( |
|||
<Text> |
|||
{unixTimestampToDate(timestamp).toLocaleDateString("en-US", { |
|||
year: "numeric", |
|||
month: "numeric", |
|||
day: "numeric", |
|||
hour: "2-digit", |
|||
minute: "2-digit", |
|||
second: "2-digit", |
|||
})} |
|||
</Text> |
|||
); |
|||
} |
@ -0,0 +1,294 @@ |
|||
import { BoxProps } from "@chakra-ui/layout"; |
|||
import { |
|||
Box, |
|||
Button, |
|||
ButtonGroup, |
|||
Center, |
|||
Checkbox, |
|||
Circle, |
|||
FormControl, |
|||
FormHelperText, |
|||
FormLabel, |
|||
Grid, |
|||
GridItem, |
|||
HStack, |
|||
Modal, |
|||
ModalBody, |
|||
ModalCloseButton, |
|||
ModalContent, |
|||
ModalFooter, |
|||
ModalHeader, |
|||
ModalOverlay, |
|||
NumberDecrementStepper, |
|||
NumberIncrementStepper, |
|||
NumberInput, |
|||
NumberInputField, |
|||
NumberInputStepper, |
|||
Skeleton, |
|||
Slider, |
|||
SliderFilledTrack, |
|||
SliderThumb, |
|||
SliderTrack, |
|||
Table, |
|||
TableCaption, |
|||
Tbody, |
|||
Td, |
|||
Text, |
|||
Tr, |
|||
useColorModeValue, |
|||
useDisclosure, |
|||
VStack, |
|||
} from "@chakra-ui/react"; |
|||
import { motion } from "framer-motion"; |
|||
import * as React from "react"; |
|||
import { useAsync } from "react-async"; |
|||
import { CfdOrderRequestPayload } from "./Types"; |
|||
|
|||
const MotionBox = motion<BoxProps>(Box); |
|||
|
|||
interface TradeProps { |
|||
order_id?: string; |
|||
min_quantity: number; |
|||
max_quantity: number; |
|||
price?: number; |
|||
margin?: string; |
|||
leverage?: number; |
|||
quantity: string; |
|||
liquidationPrice?: number; |
|||
isSubmitting: boolean; |
|||
onQuantityChange: any; |
|||
onLongSubmit: (payload: CfdOrderRequestPayload) => void; |
|||
} |
|||
|
|||
const Trade = ( |
|||
{ |
|||
min_quantity, |
|||
max_quantity, |
|||
price: priceAsNumber, |
|||
quantity, |
|||
onQuantityChange, |
|||
margin: marginAsNumber, |
|||
leverage, |
|||
liquidationPrice: liquidationPriceAsNumber, |
|||
onLongSubmit, |
|||
order_id, |
|||
}: TradeProps, |
|||
) => { |
|||
let outerCircleBg = useColorModeValue("gray.100", "gray.700"); |
|||
let innerCircleBg = useColorModeValue("gray.200", "gray.600"); |
|||
|
|||
const price = `$${priceAsNumber?.toLocaleString() || "0.0"}`; |
|||
const liquidationPrice = `$${liquidationPriceAsNumber?.toLocaleString() || "0.0"}`; |
|||
const margin = `₿${marginAsNumber?.toLocaleString() || "0.0"}`; |
|||
|
|||
const { isOpen, onOpen, onClose } = useDisclosure(); |
|||
|
|||
let { run: goLong, isLoading: isSubmitting } = useAsync({ |
|||
deferFn: async () => { |
|||
const quantityAsNumber = quantity.replace("$", ""); |
|||
|
|||
let payload: CfdOrderRequestPayload = { |
|||
order_id: order_id!, |
|||
quantity: Number.parseFloat(quantityAsNumber), |
|||
}; |
|||
await onLongSubmit(payload); |
|||
onClose(); |
|||
}, |
|||
}); |
|||
|
|||
return ( |
|||
<Center> |
|||
<Grid |
|||
templateRows="repeat(1, 1fr)" |
|||
templateColumns="repeat(1, 1fr)" |
|||
gap={4} |
|||
> |
|||
<GridItem colSpan={1}> |
|||
<Center> |
|||
<MotionBox |
|||
variants={{ |
|||
pulse: { |
|||
scale: [1, 1.05, 1], |
|||
}, |
|||
}} |
|||
// @ts-ignore: lint is complaining but should be fine :)
|
|||
transition={{ |
|||
// type: "spring",
|
|||
ease: "linear", |
|||
duration: 2, |
|||
repeat: Infinity, |
|||
}} |
|||
animate={"pulse"} |
|||
> |
|||
<Circle size="256px" bg={outerCircleBg}> |
|||
<Circle size="180px" bg={innerCircleBg}> |
|||
<MotionBox> |
|||
<Skeleton isLoaded={!!price}> |
|||
<Text fontSize={"4xl"} as="b">{price}</Text> |
|||
</Skeleton> |
|||
</MotionBox> |
|||
</Circle> |
|||
</Circle> |
|||
</MotionBox> |
|||
</Center> |
|||
</GridItem> |
|||
<GridItem colSpan={1}> |
|||
<Quantity min={min_quantity} max={max_quantity} quantity={quantity} onChange={onQuantityChange} /> |
|||
</GridItem> |
|||
<GridItem colSpan={1}> |
|||
<Leverage leverage={leverage} /> |
|||
</GridItem> |
|||
<GridItem colSpan={1}> |
|||
<Margin margin={margin} /> |
|||
</GridItem> |
|||
<GridItem colSpan={1}> |
|||
<Liquidation value={liquidationPrice} /> |
|||
</GridItem> |
|||
<GridItem colSpan={1}> |
|||
<Center> |
|||
<ButtonGroup variant="solid" padding="3" spacing="6"> |
|||
<Button colorScheme="red" size="lg" disabled h={16}> |
|||
<VStack> |
|||
<Text as="b">Short</Text> |
|||
<Text fontSize={"sm"}>{quantity.replace("$", "")}@{price}</Text> |
|||
</VStack> |
|||
</Button> |
|||
<Button colorScheme="green" size="lg" onClick={onOpen} h={16}> |
|||
<VStack> |
|||
<Text as="b">Long</Text> |
|||
<Text fontSize={"sm"}>{quantity.replace("$", "")}@{price}</Text> |
|||
</VStack> |
|||
</Button> |
|||
|
|||
<Modal isOpen={isOpen} onClose={onClose}> |
|||
<ModalOverlay /> |
|||
<ModalContent> |
|||
<ModalHeader>Market buy <b>{quantity}</b> of BTC/USD @ <b>{price}</b></ModalHeader> |
|||
<ModalCloseButton /> |
|||
<ModalBody> |
|||
<Table variant="striped" colorScheme="gray" size="sm"> |
|||
<TableCaption> |
|||
By submitting {margin} will be locked on-chain in a contract. |
|||
</TableCaption> |
|||
<Tbody> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Margin</Text></Td> |
|||
<Td>{margin}</Td> |
|||
</Tr> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Leverage</Text></Td> |
|||
<Td>{leverage}</Td> |
|||
</Tr> |
|||
<Tr> |
|||
<Td><Text as={"b"}>Liquidation Price</Text></Td> |
|||
<Td>{liquidationPrice}</Td> |
|||
</Tr> |
|||
</Tbody> |
|||
</Table> |
|||
</ModalBody> |
|||
|
|||
<ModalFooter> |
|||
<HStack> |
|||
<Button colorScheme="teal" isLoading={isSubmitting} onClick={goLong}> |
|||
Confirm |
|||
</Button> |
|||
<Checkbox defaultIsChecked>Always show this dialog</Checkbox> |
|||
</HStack> |
|||
</ModalFooter> |
|||
</ModalContent> |
|||
</Modal> |
|||
</ButtonGroup> |
|||
</Center> |
|||
</GridItem> |
|||
</Grid> |
|||
</Center> |
|||
); |
|||
}; |
|||
export default Trade; |
|||
|
|||
interface QuantityProps { |
|||
min: number; |
|||
max: number; |
|||
quantity: string; |
|||
onChange: any; |
|||
} |
|||
|
|||
function Quantity({ min, max, onChange, quantity }: QuantityProps) { |
|||
return ( |
|||
<FormControl id="quantity"> |
|||
<FormLabel>Quantity</FormLabel> |
|||
<NumberInput |
|||
min={min} |
|||
max={max} |
|||
default={min} |
|||
onChange={onChange} |
|||
value={quantity} |
|||
> |
|||
<NumberInputField /> |
|||
<NumberInputStepper> |
|||
<NumberIncrementStepper /> |
|||
<NumberDecrementStepper /> |
|||
</NumberInputStepper> |
|||
</NumberInput> |
|||
<FormHelperText>How much do you want to buy or sell?</FormHelperText> |
|||
</FormControl> |
|||
); |
|||
} |
|||
|
|||
interface LeverageProps { |
|||
leverage?: number; |
|||
} |
|||
|
|||
function Leverage({ leverage }: LeverageProps) { |
|||
return ( |
|||
<FormControl id="leverage"> |
|||
<FormLabel>Leverage (fixed to 2 atm)</FormLabel> |
|||
<Slider isReadOnly defaultValue={leverage} min={1} max={5} step={1}> |
|||
<SliderTrack> |
|||
<Box position="relative" right={10} /> |
|||
<SliderFilledTrack /> |
|||
</SliderTrack> |
|||
<SliderThumb boxSize={6}> |
|||
<Text color="black">{leverage}</Text> |
|||
</SliderThumb> |
|||
</Slider> |
|||
<FormHelperText> |
|||
How much do you want to leverage your position? |
|||
</FormHelperText> |
|||
</FormControl> |
|||
); |
|||
} |
|||
|
|||
interface MarginProps { |
|||
margin?: string; |
|||
} |
|||
|
|||
function Margin({ margin }: MarginProps) { |
|||
return ( |
|||
<VStack> |
|||
<HStack> |
|||
<Text as={"b"}>Required margin:</Text> |
|||
<Text>{margin}</Text> |
|||
</HStack> |
|||
<Text fontSize={"sm"} color={"darkgrey"}>The collateral you will need to provide</Text> |
|||
</VStack> |
|||
); |
|||
} |
|||
|
|||
interface LiquidationProps { |
|||
value?: string; |
|||
} |
|||
|
|||
function Liquidation({ value }: LiquidationProps) { |
|||
return ( |
|||
<VStack> |
|||
<HStack> |
|||
<Text as={"b"}>Liquidation price:</Text> |
|||
<Text>{value || "0.0"}</Text> |
|||
</HStack> |
|||
<Text fontSize={"sm"} color={"darkgrey"}> |
|||
You will lose your collateral if the price drops below this value |
|||
</Text> |
|||
</VStack> |
|||
); |
|||
} |
@ -0,0 +1,267 @@ |
|||
export interface WalletInfo { |
|||
balance: number; |
|||
address: string; |
|||
last_updated_at: number; |
|||
} |
|||
|
|||
export function unixTimestampToDate(unixTimestamp: number): Date { |
|||
return new Date(unixTimestamp * 1000); |
|||
} |
|||
|
|||
export interface Order { |
|||
id: string; |
|||
trading_pair: string; |
|||
position: Position; |
|||
price: number; |
|||
min_quantity: number; |
|||
max_quantity: number; |
|||
leverage: number; |
|||
liquidation_price: number; |
|||
creation_timestamp: number; |
|||
settlement_time_interval_in_secs: number; |
|||
} |
|||
|
|||
export class Position { |
|||
constructor(public key: PositionKey) {} |
|||
|
|||
public getColorScheme(): string { |
|||
switch (this.key) { |
|||
case PositionKey.LONG: |
|||
return "green"; |
|||
case PositionKey.SHORT: |
|||
return "blue"; |
|||
} |
|||
} |
|||
} |
|||
|
|||
enum PositionKey { |
|||
LONG = "Long", |
|||
SHORT = "Short", |
|||
} |
|||
|
|||
export interface Cfd { |
|||
order_id: string; |
|||
initial_price: number; |
|||
|
|||
leverage: number; |
|||
trading_pair: string; |
|||
position: Position; |
|||
liquidation_price: number; |
|||
|
|||
quantity_usd: number; |
|||
|
|||
margin: number; |
|||
|
|||
profit_btc: number; |
|||
profit_in_percent: number; |
|||
|
|||
state: State; |
|||
state_transition_timestamp: number; |
|||
details: CfdDetails; |
|||
expiry_timestamp: number; |
|||
} |
|||
|
|||
export interface CfdDetails { |
|||
tx_url_list: Tx[]; |
|||
payout?: number; |
|||
} |
|||
|
|||
export interface Tx { |
|||
label: TxLabel; |
|||
url: string; |
|||
} |
|||
|
|||
export enum TxLabel { |
|||
Lock = "Lock", |
|||
Commit = "Commit", |
|||
Cet = "Cet", |
|||
Refund = "Refund", |
|||
Collaborative = "Collaborative", |
|||
} |
|||
|
|||
export class State { |
|||
constructor(public key: StateKey) {} |
|||
|
|||
public getLabel(): string { |
|||
switch (this.key) { |
|||
case StateKey.INCOMING_ORDER_REQUEST: |
|||
return "Order Requested"; |
|||
case StateKey.OUTGOING_ORDER_REQUEST: |
|||
return "Order Requested"; |
|||
case StateKey.ACCEPTED: |
|||
return "Accepted"; |
|||
case StateKey.REJECTED: |
|||
return "Rejected"; |
|||
case StateKey.CONTRACT_SETUP: |
|||
return "Contract Setup"; |
|||
case StateKey.PENDING_OPEN: |
|||
return "Pending Open"; |
|||
case StateKey.OPEN: |
|||
return "Open"; |
|||
case StateKey.PENDING_COMMIT: |
|||
return "Pending Commit"; |
|||
case StateKey.PENDING_CLOSE: |
|||
return "Pending Close"; |
|||
case StateKey.OPEN_COMMITTED: |
|||
return "Open (commit-tx published)"; |
|||
case StateKey.INCOMING_SETTLEMENT_PROPOSAL: |
|||
return "Settlement Proposed"; |
|||
case StateKey.OUTGOING_SETTLEMENT_PROPOSAL: |
|||
return "Settlement Proposed"; |
|||
case StateKey.INCOMING_ROLL_OVER_PROPOSAL: |
|||
return "Rollover Proposed"; |
|||
case StateKey.OUTGOING_ROLL_OVER_PROPOSAL: |
|||
return "Rollover Proposed"; |
|||
case StateKey.MUST_REFUND: |
|||
return "Refunding"; |
|||
case StateKey.REFUNDED: |
|||
return "Refunded"; |
|||
case StateKey.SETUP_FAILED: |
|||
return "Setup Failed"; |
|||
case StateKey.PENDING_CET: |
|||
return "Pending CET"; |
|||
case StateKey.CLOSED: |
|||
return "Closed"; |
|||
} |
|||
} |
|||
|
|||
public getColorScheme(): string { |
|||
const default_color = "gray"; |
|||
const green = "green"; |
|||
const red = "red"; |
|||
const orange = "orange"; |
|||
|
|||
switch (this.key) { |
|||
case StateKey.ACCEPTED: |
|||
case StateKey.OPEN: |
|||
return green; |
|||
|
|||
case StateKey.REJECTED: |
|||
return red; |
|||
|
|||
case StateKey.PENDING_COMMIT: |
|||
case StateKey.OPEN_COMMITTED: |
|||
case StateKey.MUST_REFUND: |
|||
case StateKey.PENDING_CET: |
|||
case StateKey.PENDING_CLOSE: |
|||
return orange; |
|||
|
|||
case StateKey.OUTGOING_ORDER_REQUEST: |
|||
case StateKey.INCOMING_ORDER_REQUEST: |
|||
case StateKey.OUTGOING_SETTLEMENT_PROPOSAL: |
|||
case StateKey.INCOMING_SETTLEMENT_PROPOSAL: |
|||
case StateKey.INCOMING_ROLL_OVER_PROPOSAL: |
|||
case StateKey.OUTGOING_ROLL_OVER_PROPOSAL: |
|||
case StateKey.CONTRACT_SETUP: |
|||
case StateKey.PENDING_OPEN: |
|||
case StateKey.REFUNDED: |
|||
case StateKey.SETUP_FAILED: |
|||
case StateKey.CLOSED: |
|||
return default_color; |
|||
} |
|||
} |
|||
|
|||
public getGroup(): StateGroupKey { |
|||
switch (this.key) { |
|||
case StateKey.INCOMING_ORDER_REQUEST: |
|||
return StateGroupKey.PENDING_ORDER; |
|||
|
|||
case StateKey.OUTGOING_ORDER_REQUEST: |
|||
case StateKey.ACCEPTED: |
|||
case StateKey.CONTRACT_SETUP: |
|||
return StateGroupKey.OPENING; |
|||
|
|||
case StateKey.PENDING_OPEN: |
|||
case StateKey.OPEN: |
|||
case StateKey.PENDING_COMMIT: |
|||
case StateKey.OPEN_COMMITTED: |
|||
case StateKey.MUST_REFUND: |
|||
case StateKey.OUTGOING_SETTLEMENT_PROPOSAL: |
|||
case StateKey.OUTGOING_ROLL_OVER_PROPOSAL: |
|||
case StateKey.PENDING_CET: |
|||
case StateKey.PENDING_CLOSE: |
|||
return StateGroupKey.OPEN; |
|||
|
|||
case StateKey.INCOMING_SETTLEMENT_PROPOSAL: |
|||
return StateGroupKey.PENDING_SETTLEMENT; |
|||
|
|||
case StateKey.INCOMING_ROLL_OVER_PROPOSAL: |
|||
return StateGroupKey.PENDING_ROLL_OVER; |
|||
|
|||
case StateKey.REJECTED: |
|||
case StateKey.REFUNDED: |
|||
case StateKey.SETUP_FAILED: |
|||
case StateKey.CLOSED: |
|||
return StateGroupKey.CLOSED; |
|||
} |
|||
} |
|||
} |
|||
|
|||
export const enum StateKey { |
|||
OUTGOING_ORDER_REQUEST = "OutgoingOrderRequest", |
|||
INCOMING_ORDER_REQUEST = "IncomingOrderRequest", |
|||
ACCEPTED = "Accepted", |
|||
REJECTED = "Rejected", |
|||
CONTRACT_SETUP = "ContractSetup", |
|||
PENDING_OPEN = "PendingOpen", |
|||
OPEN = "Open", |
|||
PENDING_CLOSE = "PendingClose", |
|||
PENDING_COMMIT = "PendingCommit", |
|||
PENDING_CET = "PendingCet", |
|||
OPEN_COMMITTED = "OpenCommitted", |
|||
OUTGOING_SETTLEMENT_PROPOSAL = "OutgoingSettlementProposal", |
|||
INCOMING_SETTLEMENT_PROPOSAL = "IncomingSettlementProposal", |
|||
OUTGOING_ROLL_OVER_PROPOSAL = "OutgoingRollOverProposal", |
|||
INCOMING_ROLL_OVER_PROPOSAL = "IncomingRollOverProposal", |
|||
MUST_REFUND = "MustRefund", |
|||
REFUNDED = "Refunded", |
|||
SETUP_FAILED = "SetupFailed", |
|||
CLOSED = "Closed", |
|||
} |
|||
|
|||
export enum StateGroupKey { |
|||
/// A CFD which is still being set up (not on chain yet)
|
|||
OPENING = "Opening", |
|||
PENDING_ORDER = "Pending Order", |
|||
/// A CFD that is an ongoing open position (on chain)
|
|||
OPEN = "Open", |
|||
PENDING_SETTLEMENT = "Pending Settlement", |
|||
PENDING_ROLL_OVER = "Pending Roll Over", |
|||
/// A CFD that has been successfully or not-successfully terminated
|
|||
CLOSED = "Closed", |
|||
} |
|||
|
|||
export interface MarginRequestPayload { |
|||
price: number; |
|||
quantity: number; |
|||
leverage: number; |
|||
} |
|||
|
|||
export interface MarginResponse { |
|||
margin: number; |
|||
} |
|||
|
|||
export interface CfdOrderRequestPayload { |
|||
order_id: string; |
|||
quantity: number; |
|||
} |
|||
|
|||
export function intoOrder(key: string, value: any): any { |
|||
switch (key) { |
|||
case "position": |
|||
return new Position(value); |
|||
default: |
|||
return value; |
|||
} |
|||
} |
|||
|
|||
export function intoCfd(key: string, value: any): any { |
|||
switch (key) { |
|||
case "position": |
|||
return new Position(value); |
|||
case "state": |
|||
return new State(value); |
|||
default: |
|||
return value; |
|||
} |
|||
} |
@ -0,0 +1,67 @@ |
|||
import { CheckIcon, CopyIcon } from "@chakra-ui/icons"; |
|||
import { Box, Center, Divider, HStack, IconButton, Skeleton, Text, useClipboard } from "@chakra-ui/react"; |
|||
import * as React from "react"; |
|||
import Timestamp from "./Timestamp"; |
|||
import { WalletInfo } from "./Types"; |
|||
|
|||
interface WalletProps { |
|||
walletInfo: WalletInfo | null; |
|||
} |
|||
|
|||
export default function Wallet( |
|||
{ |
|||
walletInfo, |
|||
}: WalletProps, |
|||
) { |
|||
const { hasCopied, onCopy } = useClipboard(walletInfo ? walletInfo.address : ""); |
|||
const { balance, address, last_updated_at } = walletInfo || {}; |
|||
|
|||
return ( |
|||
<Center> |
|||
<Box shadow={"md"} marginBottom={5} padding={5} boxSize={"sm"}> |
|||
<Center><Text fontWeight={"bold"}>Your wallet</Text></Center> |
|||
<HStack> |
|||
<Text align={"left"}>Balance:</Text> |
|||
<Skeleton isLoaded={balance != null}> |
|||
<Text>{balance} BTC</Text> |
|||
</Skeleton> |
|||
</HStack> |
|||
<Divider marginTop={2} marginBottom={2} /> |
|||
<Skeleton isLoaded={address != null}> |
|||
<HStack> |
|||
<Text isTruncated>{address}</Text> |
|||
<IconButton |
|||
aria-label="Copy to clipboard" |
|||
icon={hasCopied ? <CheckIcon /> : <CopyIcon />} |
|||
onClick={onCopy} |
|||
/> |
|||
</HStack> |
|||
</Skeleton> |
|||
<Divider marginTop={2} marginBottom={2} /> |
|||
<HStack> |
|||
<Text align={"left"}>Updated:</Text> |
|||
<Skeleton isLoaded={last_updated_at != null}> |
|||
<Timestamp timestamp={last_updated_at!} /> |
|||
</Skeleton> |
|||
</HStack> |
|||
</Box> |
|||
</Center> |
|||
); |
|||
} |
|||
|
|||
const WalletNavBar = ({ |
|||
walletInfo, |
|||
}: WalletProps) => { |
|||
const { balance } = walletInfo || {}; |
|||
|
|||
return ( |
|||
<HStack> |
|||
<Text align={"left"} as="b">On-chain balance:</Text> |
|||
<Skeleton isLoaded={balance != null}> |
|||
<Text>{balance} BTC</Text> |
|||
</Skeleton> |
|||
</HStack> |
|||
); |
|||
}; |
|||
|
|||
export { Wallet, WalletNavBar }; |
After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 8.8 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 22 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 22 KiB |
@ -0,0 +1,13 @@ |
|||
body { |
|||
margin: 0; |
|||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', |
|||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', |
|||
sans-serif; |
|||
-webkit-font-smoothing: antialiased; |
|||
-moz-osx-font-smoothing: grayscale; |
|||
} |
|||
|
|||
code { |
|||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', |
|||
monospace; |
|||
} |
After Width: | Height: | Size: 8.8 KiB |
@ -0,0 +1,21 @@ |
|||
import { ChakraProvider } from "@chakra-ui/react"; |
|||
import React from "react"; |
|||
import ReactDOM from "react-dom"; |
|||
import { BrowserRouter } from "react-router-dom"; |
|||
import { EventSourceProvider } from "react-sse-hooks"; |
|||
import { App } from "./App"; |
|||
import "./index.css"; |
|||
import theme from "./theme"; |
|||
|
|||
ReactDOM.render( |
|||
<React.StrictMode> |
|||
<ChakraProvider theme={theme}> |
|||
<EventSourceProvider> |
|||
<BrowserRouter> |
|||
<App /> |
|||
</BrowserRouter> |
|||
</EventSourceProvider> |
|||
</ChakraProvider> |
|||
</React.StrictMode>, |
|||
document.getElementById("root"), |
|||
); |
@ -0,0 +1,10 @@ |
|||
import { extendTheme, ThemeConfig } from "@chakra-ui/react"; |
|||
|
|||
let themeConfig: ThemeConfig = { |
|||
initialColorMode: "dark", |
|||
useSystemColorMode: false, |
|||
}; |
|||
|
|||
const theme = extendTheme({ themeConfig }); |
|||
|
|||
export default theme; |
@ -0,0 +1 @@ |
|||
/// <reference types="vite/client" />
|
@ -0,0 +1,20 @@ |
|||
{ |
|||
"compilerOptions": { |
|||
"target": "ESNext", |
|||
"useDefineForClassFields": true, |
|||
"lib": ["DOM", "DOM.Iterable", "ESNext"], |
|||
"allowJs": false, |
|||
"esModuleInterop": false, |
|||
"allowSyntheticDefaultImports": true, |
|||
"strict": true, |
|||
"forceConsistentCasingInFileNames": true, |
|||
"module": "ESNext", |
|||
"moduleResolution": "Node", |
|||
"resolveJsonModule": true, |
|||
"isolatedModules": true, |
|||
"noEmit": true, |
|||
"jsx": "react", |
|||
"skipLibCheck": false |
|||
}, |
|||
"include": ["src"] |
|||
} |
@ -0,0 +1,19 @@ |
|||
import react from "@vitejs/plugin-react"; |
|||
import { resolve } from "path"; |
|||
import { defineConfig } from "vite"; |
|||
|
|||
// https://vitejs.dev/config/
|
|||
export default defineConfig({ |
|||
plugins: [react()], |
|||
build: { |
|||
rollupOptions: { |
|||
input: resolve(__dirname, `index.html`), |
|||
}, |
|||
outDir: `dist/taker`, |
|||
}, |
|||
server: { |
|||
proxy: { |
|||
"/api": "http://localhost:8000", |
|||
}, |
|||
}, |
|||
}); |