├── rescript ├── src │ ├── index.js │ ├── main.res │ ├── index.res │ ├── bindings │ │ ├── LocalStorage.res │ │ ├── ReactRouter.res │ │ ├── Big.res │ │ ├── NearApi.res │ │ └── Mui.res │ ├── utils │ │ ├── Format.res │ │ ├── Tweet.res │ │ └── Near.res │ ├── components │ │ ├── ExternalLink.res │ │ ├── SkeletonCardList.res │ │ ├── ApplicationBar │ │ │ └── styledComponents.js │ │ └── ApplicationBar.res │ ├── index.css │ ├── icons │ │ ├── InhypedIcon.res │ │ └── svg │ │ │ └── inhyped-icon.svg │ ├── App.css │ ├── contexts │ │ ├── ApplicationBarContext.res │ │ └── UserContext.res │ ├── contracts │ │ └── InhypedContract.res │ ├── shared │ │ ├── CodecUtils.res │ │ └── Api.res │ ├── hooks │ │ └── Hooks.res │ ├── pages │ │ ├── HomePage.res │ │ ├── DashboardPage.res │ │ ├── tasks │ │ │ ├── ListMyTasksPage.res │ │ │ ├── ListClaimableTasksPage.res │ │ │ └── MyTaskPage.res │ │ ├── orders │ │ │ ├── ListMyOrdersPage.res │ │ │ └── NewOrderPage.res │ │ └── transactions │ │ │ ├── DepositPage.res │ │ │ └── WithdrawPage.res │ ├── App.res │ └── logo.svg ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── inhyped-logo192.png │ ├── inhyped-logo512.png │ ├── manifest.json │ └── index.html ├── .gitignore ├── bsconfig.json └── package.json ├── typescript ├── src │ ├── react-app-env.d.ts │ ├── hooks │ │ ├── index.ts │ │ └── useLocalStorage.ts │ ├── utils │ │ ├── assertNever.ts │ │ ├── tweet.ts │ │ ├── format.ts │ │ └── near.ts │ ├── setupTests.ts │ ├── contexts │ │ └── UserContext.tsx │ ├── App.test.tsx │ ├── index.css │ ├── App.gen.tsx │ ├── icons │ │ ├── InhypedIcon.tsx │ │ └── svg │ │ │ └── inhyped-icon.svg │ ├── reportWebVitals.ts │ ├── index.tsx │ ├── components │ │ ├── externalLink.tsx │ │ ├── SkeletonCardList.tsx │ │ ├── applicationBar.tsx │ │ └── applicationBarV2.tsx │ ├── App.css │ ├── types │ │ ├── ledger.ts │ │ └── index.ts │ ├── pages │ │ ├── Dashboard.tsx │ │ ├── Home.tsx │ │ ├── ListMyTasks.tsx │ │ ├── ListMyOrders.tsx │ │ ├── ListClaimableTasks.tsx │ │ ├── MyTask.tsx │ │ ├── Deposit.tsx │ │ └── NewOrder.tsx │ ├── result.ts │ ├── near.ts │ ├── logo.svg │ ├── remoteData.ts │ ├── App.tsx │ └── apiClient.ts ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── inhyped-logo192.png │ ├── inhyped-logo512.png │ ├── manifest.json │ └── index.html ├── .gitignore ├── tsconfig.json └── package.json ├── LICENSE.md └── README.md /rescript/src/index.js: -------------------------------------------------------------------------------- 1 | ./index.bs.js -------------------------------------------------------------------------------- /typescript/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /rescript/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /typescript/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /typescript/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorage } from "./useLocalStorage"; 2 | 3 | export { useLocalStorage }; 4 | -------------------------------------------------------------------------------- /rescript/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyblake/from-typescript-to-rescript/HEAD/rescript/public/favicon.ico -------------------------------------------------------------------------------- /typescript/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyblake/from-typescript-to-rescript/HEAD/typescript/public/favicon.ico -------------------------------------------------------------------------------- /rescript/public/inhyped-logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyblake/from-typescript-to-rescript/HEAD/rescript/public/inhyped-logo192.png -------------------------------------------------------------------------------- /rescript/public/inhyped-logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyblake/from-typescript-to-rescript/HEAD/rescript/public/inhyped-logo512.png -------------------------------------------------------------------------------- /typescript/public/inhyped-logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyblake/from-typescript-to-rescript/HEAD/typescript/public/inhyped-logo192.png -------------------------------------------------------------------------------- /typescript/public/inhyped-logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/greyblake/from-typescript-to-rescript/HEAD/typescript/public/inhyped-logo512.png -------------------------------------------------------------------------------- /rescript/src/main.res: -------------------------------------------------------------------------------- 1 | ReactDOM.render( 2 | , 3 | ReactDOM.querySelector("#root")->Belt.Option.getExn, 4 | ) 5 | -------------------------------------------------------------------------------- /typescript/src/utils/assertNever.ts: -------------------------------------------------------------------------------- 1 | function assertNever(value: never): never { 2 | throw new Error(`assertNever throwed an error on: ${value}`); 3 | } 4 | 5 | export { assertNever }; 6 | -------------------------------------------------------------------------------- /rescript/src/index.res: -------------------------------------------------------------------------------- 1 | %%raw("import './index.css'") 2 | 3 | ReactDOM.render( 4 | , 5 | ReactDOM.querySelector("#root")->Belt.Option.getExn, 6 | ) 7 | -------------------------------------------------------------------------------- /rescript/src/bindings/LocalStorage.res: -------------------------------------------------------------------------------- 1 | @val @scope("localStorage") external getItem: string => Js.Nullable.t = "getItem" 2 | @val @scope("localStorage") external setItem: (string, string) => unit = "setItem" 3 | -------------------------------------------------------------------------------- /typescript/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /typescript/src/contexts/UserContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { CurrentUserView } from 'types'; 4 | import { RemoteData } from 'remoteData'; 5 | 6 | const UserContext = React.createContext>(RemoteData.newLoading()); 7 | 8 | export { UserContext }; 9 | -------------------------------------------------------------------------------- /typescript/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /rescript/src/utils/Format.res: -------------------------------------------------------------------------------- 1 | let formatNearWithPrecision = (amount: Big.t, precision: int): string => { 2 | let fixedAmount = Big.toFixed(amount, precision) 3 | `${fixedAmount} Ⓝ ` 4 | } 5 | 6 | let formatNearAmount = amount => formatNearWithPrecision(amount, 2) 7 | 8 | let formatNearAmount4 = amount => formatNearWithPrecision(amount, 4) 9 | -------------------------------------------------------------------------------- /rescript/src/components/ExternalLink.res: -------------------------------------------------------------------------------- 1 | @react.component 2 | let make = (~children: React.element, ~href: string) => { 3 | let iconStyle = ReactDOM.Style.make(~verticalAlign="middle", ~marginLeft="4px", ()) 4 | 5 | 6 | children 7 | 8 | } 9 | -------------------------------------------------------------------------------- /rescript/src/utils/Tweet.res: -------------------------------------------------------------------------------- 1 | open Types.TweetView 2 | 3 | let shortenTweetText = (TweetText(text)) => { 4 | if Js.String.length(text) > 140 { 5 | Js.String2.substring(text, ~from=0, ~to_=140) ++ "..." 6 | } else { 7 | text 8 | } 9 | } 10 | 11 | let tweetUrl = (Types.TweetId.TweetId(id)): string => { 12 | `https://twitter.com/i/web/status/${id}` 13 | } 14 | -------------------------------------------------------------------------------- /typescript/src/utils/tweet.ts: -------------------------------------------------------------------------------- 1 | function shortenTweetText(text: string): string { 2 | if (text.length > 140) { 3 | return text.substring(0, 140) + '...'; 4 | } else { 5 | return text; 6 | } 7 | } 8 | 9 | function tweetUrl(tweetId: string): string { 10 | return `https://twitter.com/i/web/status/${tweetId}`; 11 | } 12 | 13 | export { shortenTweetText, tweetUrl } 14 | -------------------------------------------------------------------------------- /typescript/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /rescript/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /typescript/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /typescript/src/App.gen.tsx: -------------------------------------------------------------------------------- 1 | /* TypeScript file generated from App.res by genType. */ 2 | /* eslint-disable import/first */ 3 | 4 | 5 | import * as React from 'react'; 6 | 7 | // @ts-ignore: Implicit any on import 8 | import * as AppBS__Es6Import from './App.bs'; 9 | const AppBS: any = AppBS__Es6Import; 10 | 11 | // tslint:disable-next-line:interface-over-type-literal 12 | export type Props = {}; 13 | 14 | export const make: React.ComponentType<{}> = AppBS.make; 15 | -------------------------------------------------------------------------------- /typescript/src/icons/InhypedIcon.tsx: -------------------------------------------------------------------------------- 1 | import {SvgIcon, SvgIconProps } from '@mui/material'; 2 | 3 | import Icon from './svg/inhyped-icon.svg'; 4 | 5 | function InhypedIcon(props: SvgIconProps) { 6 | const iconProps = { ...props, component: "object" }; 7 | return ( 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export { InhypedIcon } 15 | -------------------------------------------------------------------------------- /rescript/src/icons/InhypedIcon.res: -------------------------------------------------------------------------------- 1 | @module("./svg/inhyped-icon.svg") external inhypedIconSvg: string = "default" 2 | 3 | type fontSize = [#inherit | #medium | #large | #small] 4 | 5 | @react.component 6 | let make = (~sx: option=?, ~fontSize: option=?) => { 7 | let embedStyle = ReactDOM.Style.make(~height="100%", ()) 8 | 9 | 10 | 11 | 12 | } 13 | -------------------------------------------------------------------------------- /typescript/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /rescript/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | // Rescript artifacts 26 | *.bs.js 27 | *.gen.tsx 28 | .bsb.lock 29 | .merlin 30 | /lib/* 31 | -------------------------------------------------------------------------------- /rescript/bsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inhyped", 3 | "sources": [ 4 | { 5 | "dir": "src", 6 | "subdirs": true 7 | } 8 | ], 9 | "package-specs": [ 10 | { 11 | "module": "es6", 12 | "in-source": true 13 | } 14 | ], 15 | "suffix": ".bs.js", 16 | "reason": { "react-jsx": 3 }, 17 | "bs-dependencies": [ 18 | "@rescript/react", 19 | "@glennsl/bs-json", 20 | "rescript-asyncdata", 21 | "bs-fetch", 22 | "@ryyppy/rescript-promise", 23 | "bs-webapi" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /typescript/src/utils/format.ts: -------------------------------------------------------------------------------- 1 | import Big from 'big.js'; 2 | 3 | function formatNearAmount(amount: Big): string { 4 | return formatNearWithPrecision(amount, 2); 5 | } 6 | 7 | function formatNearAmount4(amount: Big | string): string { 8 | return formatNearWithPrecision(amount, 4); 9 | } 10 | 11 | function formatNearWithPrecision(amount: Big | string, precision: number): string { 12 | const amountBig: Big = Big(amount); 13 | return `${amountBig.toFixed(precision)} Ⓝ `; 14 | } 15 | 16 | export { formatNearAmount, formatNearAmount4 } 17 | -------------------------------------------------------------------------------- /rescript/src/components/SkeletonCardList.res: -------------------------------------------------------------------------------- 1 | @react.component 2 | let make = () => { 3 | let boxStyle = ReactDOM.Style.make(~flexGrow="1", ()) 4 | 5 | let buildCardItem = (index: int) => { 6 | 7 | 8 | 9 | } 10 | 11 | let items = [1, 2, 3, 4, 5]->Belt.Array.map(buildCardItem)->React.array 12 | 13 | items 14 | } 15 | -------------------------------------------------------------------------------- /typescript/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import { App } from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /typescript/src/components/externalLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from '@mui/material'; 3 | import LaunchIcon from '@mui/icons-material/Launch'; 4 | 5 | interface ExternalLinkProps { 6 | href: string, 7 | children: React.ReactNode, 8 | } 9 | 10 | function ExternalLink(props: ExternalLinkProps) { 11 | const { href, children } = props; 12 | 13 | return 14 | {children} 15 | 16 | ; 17 | } 18 | 19 | export { ExternalLink }; 20 | -------------------------------------------------------------------------------- /rescript/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Inhyped", 3 | "name": "Inhyped - earn crypto online", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "inhyped-logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "inhyped-logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /typescript/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Inhyped", 3 | "name": "Inhyped - earn crypto online", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "inhyped-logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "inhyped-logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /typescript/src/components/SkeletonCardList.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Skeleton, Grid, Box, 3 | } from '@mui/material'; 4 | 5 | function SkeletonCardList() { 6 | const items = [1, 2, 3, 4, 5].map((index) => { 7 | return ( 8 | 9 | 10 | 11 | 12 | ); 13 | }); 14 | 15 | return ( 16 | 17 | 18 | {items} 19 | 20 | 21 | ); 22 | } 23 | 24 | export { SkeletonCardList }; 25 | -------------------------------------------------------------------------------- /typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx", 22 | "baseUrl": "./src", 23 | "rootDir": "./src", 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /rescript/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /typescript/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /rescript/src/utils/Near.res: -------------------------------------------------------------------------------- 1 | open Types 2 | 3 | let nearTransactionUrl = (networkId: NearNetworkId.t, hash: NearTransactionHash.t): string => { 4 | let hash = NearTransactionHash.toString(hash) 5 | switch networkId { 6 | | NearNetworkId.Mainnet => `https://explorer.near.org/transactions/${hash}` 7 | | NearNetworkId.Testnet => `https://explorer.testnet.near.org/transactions/${hash}` 8 | } 9 | } 10 | 11 | let nearAccountUrl = (networkId: NearNetworkId.t, account: NearAccountId.t): string => { 12 | let account = NearAccountId.toString(account) 13 | switch networkId { 14 | | NearNetworkId.Mainnet => `https://explorer.near.org/accounts/${account}` 15 | | NearNetworkId.Testnet => `https://explorer.testnet.near.org/accounts/${account}` 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /rescript/src/contexts/ApplicationBarContext.res: -------------------------------------------------------------------------------- 1 | type t = { 2 | isOpen: bool, 3 | setIsOpen: (bool => bool) => unit, 4 | } 5 | 6 | exception ContextError(string) 7 | 8 | let context: React.Context.t = React.createContext({ 9 | isOpen: true, 10 | setIsOpen: _ => { 11 | raise(ContextError("ApplicationBarContext is not yet initialized")) 12 | }, 13 | }) 14 | 15 | module Provider = { 16 | let provider = React.Context.provider(context) 17 | 18 | @react.component 19 | let make = (~children) => { 20 | let (isOpen, setIsOpen) = Hooks.useLocalStorage( 21 | "inhypedAppBarOpen", 22 | true, 23 | Json.Decode.bool, 24 | Json.Encode.bool, 25 | ) 26 | let ctx: t = {isOpen: isOpen, setIsOpen: setIsOpen} 27 | React.createElement(provider, {"value": ctx, "children": children}) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /typescript/src/utils/near.ts: -------------------------------------------------------------------------------- 1 | import { NearNetworkId } from "near" 2 | import { assertNever } from "./assertNever"; 3 | 4 | function nearTransactionUrl(networkId: NearNetworkId, hash: string) { 5 | switch (networkId) { 6 | case "mainnet": return `https://explorer.near.org/transactions/${hash}`; 7 | case "testnet": return `https://explorer.testnet.near.org/transactions/${hash}`; 8 | default: 9 | return assertNever(networkId); 10 | } 11 | } 12 | 13 | function nearAccountUrl(networkId: NearNetworkId, account: string) { 14 | switch (networkId) { 15 | case "mainnet": return `https://explorer.near.org/accounts/${account}`; 16 | case "testnet": return `https://explorer.testnet.near.org/accounts/${account}`; 17 | default: 18 | return assertNever(networkId); 19 | } 20 | } 21 | 22 | export { nearTransactionUrl, nearAccountUrl }; 23 | -------------------------------------------------------------------------------- /rescript/src/contracts/InhypedContract.res: -------------------------------------------------------------------------------- 1 | type t = Contract(NearApi.Contract.t) 2 | 3 | let make = (account: NearApi.ConnectedWalletAccount.t, contractName: string): t => { 4 | let methods: NearApi.ContractMethods.t = { 5 | viewMethods: [], 6 | changeMethods: ["deposit"], 7 | } 8 | let nearContract = NearApi.Contract.make(account, contractName, methods) 9 | Contract(nearContract) 10 | } 11 | 12 | type depositMethodArgs = {token: string} 13 | 14 | %%private( 15 | @send 16 | external rawDeposit: ( 17 | NearApi.Contract.t, 18 | depositMethodArgs, 19 | string, // gasLimit 20 | string, 21 | ) => // amount 22 | Js.Promise.t = "deposit" 23 | ) 24 | 25 | let deposit = (contract: t, ~token: string, ~amount: string): Js.Promise.t => { 26 | let Contract(contract) = contract 27 | let gasLimit = "30000000000000" // 10 ** 13 28 | let args = {token: token} 29 | rawDeposit(contract, args, gasLimit, amount) 30 | } 31 | -------------------------------------------------------------------------------- /rescript/src/bindings/ReactRouter.res: -------------------------------------------------------------------------------- 1 | module Link = { 2 | @module("react-router-dom") @react.component 3 | external make: ( 4 | ~children: React.element, 5 | ~style: ReactDOM.Style.t=?, 6 | ~to: string, 7 | ) => React.element = "Link" 8 | } 9 | 10 | @module("react-router-dom") 11 | external useParams: unit => 'a = "useParams" 12 | 13 | module History = { 14 | type t 15 | 16 | @send 17 | external push: (t, string) => unit = "push" 18 | } 19 | 20 | @module("react-router-dom") 21 | external useHistory: unit => History.t = "useHistory" 22 | 23 | module BrowserRouter = { 24 | @module("react-router-dom") @react.component 25 | external make: (~children: React.element) => React.element = "BrowserRouter" 26 | } 27 | 28 | module Switch = { 29 | @module("react-router-dom") @react.component 30 | external make: (~children: React.element) => React.element = "Switch" 31 | } 32 | 33 | module Route = { 34 | @module("react-router-dom") @react.component 35 | external make: (~children: React.element, ~path: string, ~exact: bool=?) => React.element = 36 | "Route" 37 | } 38 | -------------------------------------------------------------------------------- /typescript/src/types/ledger.ts: -------------------------------------------------------------------------------- 1 | 2 | interface WithdrawParams { 3 | recipientNearAccountId: string; 4 | amount: string; 5 | } 6 | 7 | type WithdrawErrorView = 8 | | { tag: "InvalidRecipient" } 9 | | { tag: "RecipientAccountDoesNotExist", 10 | content: { 11 | recipientAccountId: string, 12 | } 13 | } 14 | | { tag: "RequestedAmountTooSmall"; 15 | content: { 16 | minAmount: string; 17 | requestedAmount: string; 18 | }; 19 | } 20 | | { tag: "RequestedAmountTooHigh"; 21 | content: { 22 | maxAmount: string; 23 | requestedAmount: string; 24 | }; 25 | } 26 | | { tag: "InsufficientFunds"; 27 | content: { 28 | availableBalance: string; 29 | requestedAmount: string; 30 | }; 31 | }; 32 | 33 | interface WithdrawResponseView { 34 | nearTransactionHash: string; 35 | amount: string; 36 | recipientNearAccountId: string; 37 | } 38 | 39 | export type { WithdrawParams, WithdrawErrorView, WithdrawResponseView }; 40 | -------------------------------------------------------------------------------- /typescript/src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | 3 | type SetValue = (newValue: T) => void; 4 | 5 | export const useLocalStorage = (key: string, initialValue: T): [T, SetValue] => { 6 | const [storedValue, setStoredValue] = useState(() => { 7 | try { 8 | const jsonValue = window.localStorage.getItem(key) 9 | return jsonValue ? JSON.parse(jsonValue) : initialValue; 10 | } catch (error) { 11 | console.error(`Failed to read key "${key}" from localStorage: `, error); 12 | return initialValue; 13 | } 14 | }); 15 | 16 | const setValue = (value: T): void => { 17 | try { 18 | const valueToStore = value instanceof Function ? value(storedValue) : value; 19 | setStoredValue(valueToStore); 20 | window.localStorage.setItem(key, JSON.stringify(valueToStore)); 21 | } catch (error) { 22 | console.error(`Failed to set key "${key}" in localStorage: `, error); 23 | } 24 | }; 25 | 26 | return [storedValue, setValue] 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2022 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /rescript/src/shared/CodecUtils.res: -------------------------------------------------------------------------------- 1 | module D = Json.Decode 2 | module E = Json.Encode 3 | 4 | // Helper functions to decode variant/enum types. 5 | %%private( 6 | let decodeDict = (json: Js.Json.t): Js.Dict.t<'a> => { 7 | switch Js.Json.classify(json) { 8 | | Js.Json.JSONObject(value) => value 9 | | _ => raise(D.DecodeError(`Expected to be a dictionary: ${Json.stringify(json)}`)) 10 | } 11 | } 12 | ) 13 | 14 | let pickTag = (json: Js.Json.t) => { 15 | let tagJson = json->decodeDict->Js.Dict.get("tag") 16 | switch tagJson { 17 | | Some(tagValue) => D.string(tagValue) 18 | | None => raise(D.DecodeError(`Expected to have field "tag" on: ${Json.stringify(json)}`)) 19 | } 20 | } 21 | let pickContent = (json: Js.Json.t): Js.Json.t => { 22 | let contentJson = json->decodeDict->Js.Dict.get("content") 23 | switch contentJson { 24 | | Some(content) => content 25 | | None => raise(D.DecodeError(`Expected to have field "content" on: ${Json.stringify(json)}`)) 26 | } 27 | } 28 | 29 | let decodeBig = (json: Js.Json.t): Big.t => { 30 | json->D.string->Big.fromString 31 | } 32 | 33 | let encodeBig = (big: Big.t): Js.Json.t => { 34 | big->Big.toString->E.string 35 | } 36 | -------------------------------------------------------------------------------- /rescript/src/bindings/Big.res: -------------------------------------------------------------------------------- 1 | type t 2 | 3 | @send 4 | external toFixed: (t, int) => string = "toFixed" 5 | 6 | // Returns true if the value of &a greater 7 | // than the value of &b, otherwise returns false 8 | @send 9 | external gt: (t, t) => bool = "gt" 10 | 11 | // Returns true if the value of &a is less than 12 | // the value of &b, otherwise returns false. 13 | @send 14 | external lt: (t, t) => bool = "lt" 15 | 16 | // / Returns a Big number whose 17 | // value is the division of &a by &b 18 | @send 19 | external div: (t, t) => t = "div" 20 | 21 | // Converters 22 | // Big number constructor from a float 23 | @module("big.js") 24 | external makeFromFloat: float => t = "Big" 25 | let fromFloat = makeFromFloat 26 | 27 | @module("big.js") 28 | external makeFromString: string => t = "Big" 29 | let fromString = makeFromString 30 | 31 | @module("big.js") 32 | external fromInt: int => t = "Big" 33 | 34 | @send 35 | external toString: t => string = "toString" 36 | 37 | @send 38 | external times: (t, t) => t = "times" 39 | 40 | // More 41 | // 42 | let parse = (value: string): option => { 43 | try { 44 | Some(fromString(value)) 45 | } catch { 46 | | Js.Exn.Error(_) => None 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /rescript/src/hooks/Hooks.res: -------------------------------------------------------------------------------- 1 | let useLocalStorage = ( 2 | key: string, 3 | defaultValue: 'a, 4 | decoder: Js.Json.t => 'a, 5 | encoder: 'a => Js.Json.t, 6 | ): ('a, ('a => 'a) => unit) => { 7 | let (storedValue, setStoredValue) = React.useState(() => { 8 | try { 9 | let maybeContent = LocalStorage.getItem(key)->Js.Nullable.toOption 10 | switch maybeContent { 11 | | None => defaultValue 12 | | Some(content) => 13 | switch Json.parse(content) { 14 | | None => defaultValue 15 | | Some(json) => json->decoder 16 | } 17 | } 18 | } catch { 19 | | err => { 20 | Js.Console.error2(`Failed to read key "${key}" from localStorage: `, err) 21 | defaultValue 22 | } 23 | } 24 | }) 25 | 26 | let setValue = (set: 'a => 'a): unit => { 27 | try { 28 | setStoredValue(oldValue => { 29 | let newValue = set(oldValue) 30 | let jsonStr = newValue->encoder->Json.stringify 31 | LocalStorage.setItem(key, jsonStr) 32 | newValue 33 | }) 34 | } catch { 35 | | err => Js.Console.error2(`Failed to set key "${key}" in localStorage: `, err) 36 | } 37 | } 38 | 39 | (storedValue, setValue) 40 | } 41 | -------------------------------------------------------------------------------- /rescript/src/contexts/UserContext.res: -------------------------------------------------------------------------------- 1 | open AsyncData 2 | open Types 3 | 4 | type userContextValue = { 5 | user: AsyncData.t>, 6 | reloadUser: unit => unit, 7 | } 8 | 9 | let context: React.Context.t = React.createContext({ 10 | user: Loading, 11 | reloadUser: () => (), 12 | }) 13 | 14 | module Provider = { 15 | let provider = React.Context.provider(context) 16 | 17 | @react.component 18 | let make = (~children) => { 19 | let (user, setUser) = React.useState(_ => Loading) 20 | 21 | let reloadUser = React.useCallback0(() => { 22 | Api.getSeed() 23 | ->Promise.then(seedData => { 24 | let {currentUser} = seedData 25 | setUser(_ => Done(Some(currentUser))) 26 | Promise.resolve() 27 | }) 28 | ->Promise.catch(err => { 29 | Js.Console.error2("Error on attempt to get seed data: ", err) 30 | setUser(_ => Done(None)) 31 | Promise.resolve() 32 | }) 33 | ->ignore 34 | }) 35 | 36 | React.useEffect0(() => { 37 | reloadUser() 38 | None 39 | }) 40 | 41 | let value = {user: user, reloadUser: reloadUser} 42 | 43 | React.createElement(provider, {"value": value, "children": children}) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## From TypeScript To Rescript 2 | 3 | This is an example of [Inhyped](https://inhyped.com/) frontend codebase initially written in TypeScript and then 4 | fully rewritten in [ReScript](https://rescript-lang.org/). 5 | 6 | It's published for educational purpose and pursues a goal to demonstrate what a real ReScript project may look like. 7 | 8 | The rewriting process was accompanied with [tweets with hashtag #FromTypescriptToRescript](https://twitter.com/hashtag/FromTypescriptToRescript). 9 | 10 | The author does not consider himself an excellent frontend developer and both versions are far from being perfectly polished, so keep it in mind. 11 | 12 | The source code comes without corresponding backend part. To get a feeling of what the project does, 13 | it's recommended visiting [inhyped.com](https://inhyped.com/) 14 | 15 | ### Running ReScript 16 | 17 | Install dependencies: 18 | 19 | ``` 20 | cd rescript 21 | npm install 22 | ``` 23 | 24 | Start ReScript/OCaml compiler with watcher: 25 | 26 | ``` 27 | npm run watch 28 | ``` 29 | 30 | Serve the assets: 31 | 32 | ``` 33 | npm run start 34 | ``` 35 | 36 | ## Similar Projects 37 | 38 | * [ReScript RealWorld App](https://github.com/jihchi/rescript-react-realworld-example-app) 39 | 40 | ## License 41 | 42 | MIT 43 | -------------------------------------------------------------------------------- /rescript/src/pages/HomePage.res: -------------------------------------------------------------------------------- 1 | open Mui 2 | 3 | let renderHelloContent = () => { 4 | let inhypedIconStyle = ReactDOM.Style.make( 5 | ~width="100px", 6 | ~height="100px", 7 | ~marginRight="4px", 8 | ~position="relative", 9 | ~top="12px", 10 | (), 11 | ) 12 | 13 | 14 | 15 | 16 | {React.string("Inhyped")} 17 | 18 | 19 | {React.string( 20 | "Easy way to promote your tweets or earn crypto by using your twitter account.", 21 | )} 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | 32 | 33 | } 34 | 35 | @react.component 36 | let make = (~state: [#loading | #notLoggedIn]) => { 37 | switch state { 38 | | #loading => <> 39 | | #notLoggedIn => renderHelloContent() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /rescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.5.0", 7 | "@emotion/styled": "^11.3.0", 8 | "@glennsl/bs-json": "^5.0.4", 9 | "@mui/icons-material": "^5.0.5", 10 | "@mui/lab": "^5.0.0-alpha.57", 11 | "@mui/material": "^5.0.6", 12 | "@mui/styles": "^5.0.2", 13 | "@rescript/react": "^0.10.3", 14 | "@ryyppy/rescript-promise": "^2.1.0", 15 | "big.js": "^6.1.1", 16 | "bs-fetch": "^0.6.2", 17 | "bs-webapi": "^0.19.1", 18 | "buffer": "^6.0.3", 19 | "near-api-js": "^0.44.2", 20 | "react": "^17.0.2", 21 | "react-dom": "^17.0.2", 22 | "react-router-dom": "^5.2.0", 23 | "react-scripts": "^5.0.0", 24 | "rescript-asyncdata": "^4.0.0" 25 | }, 26 | "scripts": { 27 | "build": "react-scripts build", 28 | "start": "react-scripts start", 29 | "watch": "rescript build -with-deps -w", 30 | "format": "rescript format -all", 31 | "clean": "rescript clean" 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "rescript": "^9.1.4" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /rescript/src/pages/DashboardPage.res: -------------------------------------------------------------------------------- 1 | @react.component 2 | let make = (~userName: string) => { 3 | open Mui 4 | 5 | 6 |
7 |
8 | 9 | 10 | 12 | {React.string("Hello " ++ userName)} 13 | 14 | 16 | 17 | {React.string( 18 | "Start by depositing some NEAR to your balance in order to promote your tweets.", 19 | )} 20 | 21 | 22 | 24 | {React.string("OR")} 25 | 26 | 28 | 29 | {React.string("Check available tasks if you want to earn NEAR")} 30 | 31 | 32 | 33 |
34 |
35 |
36 | } 37 | -------------------------------------------------------------------------------- /typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.5.0", 7 | "@emotion/styled": "^11.3.0", 8 | "@mui/icons-material": "^5.0.5", 9 | "@mui/lab": "^5.0.0-alpha.57", 10 | "@mui/material": "^5.0.6", 11 | "@mui/styles": "^5.0.2", 12 | "@testing-library/jest-dom": "^5.11.4", 13 | "@testing-library/react": "^11.1.0", 14 | "@testing-library/user-event": "^12.1.10", 15 | "@types/jest": "^26.0.15", 16 | "@types/node": "^12.0.0", 17 | "@types/react": "^17.0.0", 18 | "@types/react-dom": "^17.0.0", 19 | "big.js": "^6.1.1", 20 | "lodash": "^4.17.21", 21 | "near-api-js": "^0.42.0", 22 | "react": "^17.0.2", 23 | "react-dom": "^17.0.2", 24 | "react-router-dom": "^5.2.0", 25 | "react-scripts": "4.0.3", 26 | "typescript": "^4.1.2", 27 | "web-vitals": "^1.0.1" 28 | }, 29 | "scripts": { 30 | "start": "react-scripts start", 31 | "build": "GENERATE_SOURCEMAP=false react-scripts build", 32 | "test": "react-scripts test", 33 | "eject": "react-scripts eject" 34 | }, 35 | "eslintConfig": { 36 | "extends": [ 37 | "react-app", 38 | "react-app/jest" 39 | ] 40 | }, 41 | "browserslist": { 42 | "production": [ 43 | ">0.2%", 44 | "not dead", 45 | "not op_mini all" 46 | ], 47 | "development": [ 48 | "last 1 chrome version", 49 | "last 1 firefox version", 50 | "last 1 safari version" 51 | ] 52 | }, 53 | "devDependencies": { 54 | "@types/big.js": "^6.1.1", 55 | "@types/lodash": "^4.14.178", 56 | "@types/react-router-dom": "^5.1.8" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /typescript/src/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Typography, Container, Toolbar } from '@mui/material'; 4 | import type { CurrentUserView } from 'types'; 5 | import { ApplicationBar } from 'components/applicationBarV2'; 6 | import { Link as RouterLink } from 'react-router-dom'; 7 | 8 | function DashboardPage({currentUser}: {currentUser: CurrentUserView}) { 9 | const content = ( 10 |
11 |
12 | 13 | 14 | 15 | Hello {currentUser.name} 16 | 17 | 18 | Start by depositing some NEAR to your balance in order to promote your tweets. 19 | 20 | 21 | OR 22 | 23 | 24 | Check available tasks if you want to earn NEAR 25 | 26 | 27 |
28 |
29 | ); 30 | 31 | return ; 35 | } 36 | 37 | export { DashboardPage }; 38 | -------------------------------------------------------------------------------- /rescript/src/components/ApplicationBar/styledComponents.js: -------------------------------------------------------------------------------- 1 | import { 2 | AppBar as MuiAppBar, 3 | Drawer as MuiDrawer, 4 | // AppBarProps as MuiAppBarProps , 5 | } from '@mui/material'; 6 | import { styled } from '@mui/material/styles'; 7 | 8 | // interface StyledAppBarProps extends MuiAppBarProps { 9 | // open: boolean; 10 | // } 11 | 12 | const StyledAppBar = styled(MuiAppBar, { 13 | shouldForwardProp: (prop) => prop !== 'open', 14 | })(({ theme, open }) => ({ 15 | zIndex: theme.zIndex.drawer + 1, 16 | transition: theme.transitions.create(['width', 'margin'], { 17 | easing: theme.transitions.easing.sharp, 18 | duration: theme.transitions.duration.leavingScreen, 19 | }), 20 | ...(open && { 21 | marginLeft: drawerWidth, 22 | width: `calc(100% - ${drawerWidth}px)`, 23 | transition: theme.transitions.create(['width', 'margin'], { 24 | easing: theme.transitions.easing.sharp, 25 | duration: theme.transitions.duration.enteringScreen, 26 | }), 27 | }), 28 | })); 29 | 30 | const drawerWidth = 240; 31 | 32 | const StyledAppDrawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })( 33 | ({ theme, open }) => ({ 34 | '& .MuiDrawer-paper': { 35 | position: 'relative', 36 | whiteSpace: 'nowrap', 37 | width: drawerWidth, 38 | transition: theme.transitions.create('width', { 39 | easing: theme.transitions.easing.sharp, 40 | duration: theme.transitions.duration.enteringScreen, 41 | }), 42 | boxSizing: 'border-box', 43 | ...(!open && { 44 | overflowX: 'hidden', 45 | transition: theme.transitions.create('width', { 46 | easing: theme.transitions.easing.sharp, 47 | duration: theme.transitions.duration.leavingScreen, 48 | }), 49 | width: theme.spacing(7), 50 | [theme.breakpoints.up('sm')]: { 51 | width: theme.spacing(9), 52 | }, 53 | }), 54 | }, 55 | }), 56 | ); 57 | 58 | export { StyledAppBar, StyledAppDrawer } 59 | -------------------------------------------------------------------------------- /rescript/src/App.res: -------------------------------------------------------------------------------- 1 | open AsyncData 2 | open ReactRouter 3 | 4 | module AppRouter = { 5 | @react.component 6 | let make = (~seed: Types.SeedView.t) => { 7 | let {currentUser, nearConfig} = seed 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | } 26 | } 27 | 28 | module Providers = { 29 | @react.component 30 | let make = (~children: React.element) => { 31 | 32 | {children} 33 | 34 | } 35 | } 36 | 37 | @genType @react.component 38 | let make = () => { 39 | let (seed, setSeed) = React.useState(_ => Loading) 40 | 41 | let loadMainState = React.useCallback0(() => { 42 | Api.getSeed() 43 | ->Promise.then(seed => { 44 | setSeed(_ => Done(Ok(seed))) 45 | Promise.resolve() 46 | }) 47 | ->Promise.catch(_err => { 48 | setSeed(_ => Done(Error())) 49 | Promise.resolve() 50 | }) 51 | ->ignore 52 | }) 53 | 54 | React.useEffect1(() => { 55 | loadMainState() 56 | None 57 | }, [loadMainState]) 58 | 59 | 60 | {switch seed { 61 | | NotAsked 62 | | Loading => 63 | 64 | | Done(Error(_)) => 65 | | Done(Ok(seed)) => 66 | }} 67 | 68 | } 69 | -------------------------------------------------------------------------------- /rescript/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 35 | Inhyped 36 | 37 | 38 | 39 |
40 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /typescript/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 35 | Inhyped 36 | 37 | 38 | 39 |
40 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /typescript/src/result.ts: -------------------------------------------------------------------------------- 1 | 2 | type ResultInner = 3 | | { type: 'OK', value: T } 4 | | { type: 'ERROR', error: E }; 5 | 6 | interface MatchHandlers { 7 | ok: (value: T) => R, 8 | err: (error: E) => R, 9 | } 10 | 11 | class Result { 12 | inner: ResultInner; 13 | 14 | constructor(inner: ResultInner) { 15 | this.inner = inner; 16 | } 17 | 18 | public static newOk(value: T): Result { 19 | const inner: ResultInner = { type: 'OK', value, }; 20 | return new Result(inner); 21 | } 22 | 23 | public static newErr(error: E): Result { 24 | const inner: ResultInner = { type: 'ERROR', error, }; 25 | return new Result(inner); 26 | } 27 | 28 | match(handlers: MatchHandlers): R { 29 | switch (this.inner.type) { 30 | case 'OK': { 31 | return handlers.ok(this.inner.value); 32 | } 33 | case 'ERROR': { 34 | return handlers.err(this.inner.error); 35 | } 36 | default: 37 | throw new Error("Result.match() is supposed to be exhaustive"); 38 | } 39 | } 40 | 41 | mapOk(transform: (val: T) => T2): Result { 42 | return this.match({ 43 | ok: (val) => Result.newOk(transform(val)), 44 | err: (e) => Result.newErr(e), 45 | }); 46 | } 47 | 48 | isOk(): boolean { 49 | return this.match({ 50 | ok: (_) => true, 51 | err: (_) => false, 52 | }); 53 | } 54 | 55 | isErr(): boolean { 56 | return !this.isOk(); 57 | } 58 | 59 | err(): E | null { 60 | return this.match({ 61 | ok: (_) => null, 62 | err: (e) => e, 63 | }); 64 | } 65 | 66 | ok(): T | null { 67 | return this.match({ 68 | ok: (val) => val, 69 | err: (_) => null, 70 | }); 71 | } 72 | 73 | unwrap(): T { 74 | return this.match({ 75 | ok: (val) => val, 76 | err: (e) => { 77 | throw new Error(`Cannot unwrap Result with an error: ${e}`); 78 | }, 79 | }); 80 | } 81 | } 82 | 83 | export { Result }; 84 | -------------------------------------------------------------------------------- /typescript/src/near.ts: -------------------------------------------------------------------------------- 1 | import * as nearAPI from "near-api-js" 2 | import type { NearConfigView } from 'types'; 3 | 4 | // Resources: 5 | // * https://github.com/near-examples/guest-book/blob/master/src/App.js#L30-L43 6 | // * https://docs.near.org/docs/api/naj-quick-reference#call-contract 7 | // * https://github.com/mehtaphysical/near-js/blob/dd0e1ad79990844764c42c7dd3728a8c69f29eae/packages/near-transaction-manager/src/sender/WalletTransactionSender.ts#L61-L72 8 | // * https://github.com/ref-finance/ref-ui/blob/e445129d524021d48d0ff13cf59bb52532a8fe00/src/services/near.ts#L84-L105 9 | 10 | 11 | // const [nearConfig, setNearConfig] = React.useState(null); 12 | // const [near, setNear] = React.useState(null); 13 | // const [nearContract, setNearContract] = React.useState(null); 14 | // const [nearWalletAccount, setNearWalletAccount] 15 | 16 | 17 | interface DepositMethoArgs { 18 | token: string, 19 | } 20 | 21 | interface ContractChangeMethods { 22 | deposit: (args: DepositMethoArgs, gasLimit: string, amount: string) => Promise; 23 | } 24 | 25 | type AppContract = nearAPI.Contract & ContractChangeMethods; 26 | 27 | type NearNetworkId = "mainnet" | "testnet"; 28 | 29 | interface NearEnv { 30 | config: NearConfigView; 31 | near: nearAPI.Near; 32 | walletConnection: nearAPI.WalletConnection; 33 | walletAccount: nearAPI.WalletConnection; 34 | contract: AppContract; 35 | } 36 | 37 | async function setupNear(config: NearConfigView): Promise { 38 | const near = await nearAPI.connect( 39 | Object.assign( 40 | { deps: { keyStore: new nearAPI.keyStores.BrowserLocalStorageKeyStore() } }, 41 | config 42 | ) 43 | ); 44 | const walletConnection = new nearAPI.WalletConnection(near, null); 45 | const walletAccount = new nearAPI.WalletAccount(near, null); 46 | 47 | const contract = new nearAPI.Contract(walletConnection.account(), config.contractName, { 48 | viewMethods: [], 49 | changeMethods: ['deposit'], 50 | // sender: walletAccount.getAccountId(), 51 | }) as AppContract; 52 | 53 | return { 54 | config, 55 | near, 56 | walletConnection, 57 | walletAccount, 58 | contract, 59 | }; 60 | } 61 | 62 | export { 63 | setupNear, 64 | }; 65 | 66 | export type { NearEnv, NearNetworkId }; 67 | -------------------------------------------------------------------------------- /rescript/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /typescript/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rescript/src/pages/tasks/ListMyTasksPage.res: -------------------------------------------------------------------------------- 1 | module MainContent = { 2 | open AsyncData 3 | open Promise 4 | open Types 5 | 6 | let renderMyTask = (taskWithTweet: TaskWithTweetView.t) => { 7 | open Mui 8 | 9 | let {task, tweet} = taskWithTweet 10 | 11 | 12 | 13 | {("Reward: " ++ Format.formatNearAmount4(task.contractorReward))->React.string} 14 | 15 | 16 | {("Status: " ++ TaskStatusView.display(task.status))->React.string} 17 | 18 | 19 | {tweet.text->Tweet.shortenTweetText->React.string} 20 | 21 | 22 | 23 | 28 | 29 | 30 | } 31 | 32 | let renderTaskList = (tasks: array) => { 33 | let taskItems = 34 | tasks 35 | ->Belt.Array.map(task => { 36 | let key = TaskId.toString(task.task.id) 37 | {renderMyTask(task)} 38 | }) 39 | ->React.array 40 | 41 | let boxStyle = ReactDOM.Style.make(~flexGrow="1", ()) 42 | taskItems 43 | } 44 | 45 | @react.component 46 | let make = () => { 47 | let (myTasks, setMyTasks) = React.useState(_ => Loading) 48 | 49 | React.useEffect0(() => { 50 | Api.getMyTasks() 51 | ->then(tasks => tasks->(tasks => (_ => Done(tasks))->setMyTasks->resolve)) 52 | ->ignore 53 | None 54 | }) 55 | 56 | switch myTasks { 57 | | NotAsked 58 | | Loading => 59 | 60 | | Done(tasks) => renderTaskList(tasks) 61 | } 62 | } 63 | } 64 | 65 | @react.component 66 | let make = () => { 67 | 68 | 69 | 70 | {React.string("New tasks")} 71 | {React.string("Past tasks")} 72 | 73 | 74 | 75 | 76 | {React.string("My Tasks")} 77 | 78 | 79 | 80 | 81 | } 82 | -------------------------------------------------------------------------------- /typescript/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Button, Typography, Grid, Container, Toolbar, Skeleton } from '@mui/material'; 4 | import { makeStyles } from '@mui/styles'; 5 | import { ApplicationBar } from 'components/applicationBar'; 6 | import { assertNever } from 'utils/assertNever'; 7 | import { InhypedIcon } from 'icons/InhypedIcon'; 8 | 9 | const useStyles = makeStyles({ 10 | icon: { 11 | marginRight: '8px', 12 | }, 13 | heroContent: { 14 | padding: '32px 0px 24px', 15 | }, 16 | heroButtons: { 17 | marginTop: '16px', 18 | }, 19 | }); 20 | 21 | 22 | type State = "loading" | "notLoggedIn"; 23 | 24 | interface HomePageProps { 25 | state: State, 26 | } 27 | 28 | function HomePage(props: HomePageProps) { 29 | const { state } = props; 30 | return ( 31 | <> 32 | 33 |
34 | 35 |
36 | 37 | ); 38 | } 39 | 40 | function Content({state}: {state: State}) { 41 | switch (state) { 42 | case "loading": return ; 43 | case "notLoggedIn": return ; 44 | default: 45 | return assertNever(state); 46 | } 47 | } 48 | 49 | 50 | function LoadingContent() { 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | } 60 | 61 | function HelloContent() { 62 | const classes = useStyles(); 63 | 64 | return ( 65 |
66 | 67 | 68 | 69 | Inhyped 70 | 71 | 72 | Easy way to promote your tweets or earn crypto by using your twitter account. 73 | 74 |
75 | 76 | 77 | 78 | 81 | 82 | 83 | 84 |
85 |
86 |
87 | ); 88 | } 89 | 90 | export { HomePage }; 91 | -------------------------------------------------------------------------------- /typescript/src/remoteData.ts: -------------------------------------------------------------------------------- 1 | 2 | type RemoteDataInner = 3 | { type: 'NOT_ASKED' } 4 | | { type: 'LOADING' } 5 | | { type: 'FAILURE', error: E } 6 | | { type: 'SUCCESS', data: T } 7 | 8 | interface MatchHandlers { 9 | notAsked: () => R, 10 | loading: () => R, 11 | success: (data: T) => R, 12 | failure: (error: E) => R, 13 | } 14 | 15 | class RemoteData { 16 | inner: RemoteDataInner = { type: 'NOT_ASKED' }; 17 | 18 | constructor(inner: RemoteDataInner) { 19 | this.inner = inner; 20 | } 21 | 22 | public static newNotAsked(): RemoteData { 23 | const inner: RemoteDataInner = { type: 'NOT_ASKED' }; 24 | return new RemoteData(inner); 25 | } 26 | 27 | static newLoading(): RemoteData { 28 | const inner: RemoteDataInner = { type: 'LOADING' }; 29 | return new RemoteData(inner); 30 | } 31 | 32 | static newFailure(error: E): RemoteData { 33 | const inner: RemoteDataInner = { type: 'FAILURE', error }; 34 | return new RemoteData(inner); 35 | } 36 | 37 | static newSuccess(data: T): RemoteData { 38 | const inner: RemoteDataInner = { type: 'SUCCESS', data }; 39 | return new RemoteData(inner); 40 | } 41 | 42 | match(handlers: MatchHandlers): R { 43 | switch (this.inner.type) { 44 | case 'NOT_ASKED': { 45 | return handlers.notAsked(); 46 | } 47 | case 'LOADING': { 48 | return handlers.loading(); 49 | } 50 | case 'FAILURE': { 51 | return handlers.failure(this.inner.error); 52 | } 53 | case 'SUCCESS': { 54 | return handlers.success(this.inner.data); 55 | } 56 | default: 57 | throw new Error("match() is supposed to be exhaustive"); 58 | } 59 | } 60 | 61 | mapSuccess(transform: (val: T) => T2): RemoteData { 62 | switch (this.inner.type) { 63 | case 'SUCCESS': { 64 | const newData: T2 = transform(this.inner.data); 65 | return RemoteData.newSuccess(newData); 66 | } 67 | case 'NOT_ASKED': { 68 | return RemoteData.newNotAsked(); 69 | } 70 | case 'LOADING': { 71 | return RemoteData.newLoading(); 72 | } 73 | case 'FAILURE': { 74 | return RemoteData.newFailure(this.inner.error); 75 | } 76 | default: 77 | throw new Error("match() is supposed to be exhaustive"); 78 | } 79 | } 80 | 81 | isLoading(): boolean { 82 | return this.match({ 83 | notAsked: () => false, 84 | loading: () => true, 85 | success: (_) => false, 86 | failure: (_) => false, 87 | }); 88 | } 89 | } 90 | 91 | export { RemoteData }; 92 | -------------------------------------------------------------------------------- /typescript/src/components/applicationBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link as RouterLink } from 'react-router-dom'; 3 | import { AppBar, Typography, Toolbar, IconButton, Avatar, Menu, MenuItem } from '@mui/material'; 4 | import { makeStyles } from '@mui/styles'; 5 | 6 | import type { CurrentUserView } from 'types'; 7 | import { formatNearAmount } from 'utils/format'; 8 | 9 | const useStyles = makeStyles({ 10 | icon: { 11 | marginRight: "8px", 12 | }, 13 | balance: { 14 | marginRight: "8px", 15 | }, 16 | title: { 17 | flexGrow: 1, 18 | }, 19 | menu: { 20 | marginTop: "20px", 21 | } 22 | }); 23 | 24 | interface ApplicationBarProps { 25 | user: CurrentUserView | null; 26 | } 27 | 28 | function ApplicationBar(props: ApplicationBarProps) { 29 | const classes = useStyles(); 30 | const { user } = props; 31 | 32 | const rightSide = user ? : null; 33 | 34 | return ( 35 | 36 | 37 | 38 | Inhyped 39 | 40 | 41 | {rightSide} 42 | 43 | 44 | ); 45 | } 46 | 47 | 48 | interface RightSideProps { 49 | user: CurrentUserView; 50 | } 51 | function RightSide(props: RightSideProps) { 52 | const { user } = props; 53 | 54 | const classes = useStyles(); 55 | const [anchorEl, setAnchorEl] = React.useState(null); 56 | const open = Boolean(anchorEl); 57 | 58 | const handleMenu = (event: React.MouseEvent) => { 59 | setAnchorEl(event.currentTarget); 60 | }; 61 | 62 | const handleClose = () => { 63 | setAnchorEl(null); 64 | }; 65 | 66 | return ( 67 | <> 68 | Balance: {formatNearAmount(user.balance)} 69 | 76 | 77 | 78 | 79 | 95 | Deposit 96 | Withdraw 97 | New Order 98 | My Orders 99 | Claimable Tasks 100 | My Tasks 101 | 102 | 103 | 104 | ); 105 | } 106 | 107 | export { ApplicationBar }; 108 | -------------------------------------------------------------------------------- /typescript/src/pages/ListMyTasks.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { TaskWithTweetView } from 'types'; 4 | 5 | import { 6 | Typography, Container, Button, 7 | Grid, Box, Card, CardContent, CardActions, Toolbar, Stack 8 | } from '@mui/material'; 9 | import { Link } from "react-router-dom"; 10 | 11 | import { ApiClient } from 'apiClient'; 12 | import { RemoteData } from 'remoteData'; 13 | import { formatNearAmount4 } from 'utils/format'; 14 | import { shortenTweetText } from 'utils/tweet'; 15 | import { ApplicationBar } from 'components/applicationBarV2'; 16 | import { SkeletonCardList } from 'components/SkeletonCardList'; 17 | 18 | function ListMyTasksPage() { 19 | const content = ( 20 | <> 21 | 22 | 23 | New tasks 24 | Past tasks 25 | 26 | 27 | 28 | 29 | My Tasks 30 | 31 | 32 | 33 | 34 | ); 35 | 36 | return ; 40 | } 41 | 42 | function MainContent() { 43 | let [myTasks, setMyTasks] = React.useState(RemoteData.newNotAsked, Error>()); 44 | 45 | React.useEffect(() => { 46 | const apiClient = new ApiClient(); 47 | apiClient.getMyTasks().then((tasks) => { 48 | setMyTasks(RemoteData.newSuccess(tasks)); 49 | }).catch(err => setMyTasks(RemoteData.newFailure(err))) 50 | }, []); 51 | 52 | return myTasks.match({ 53 | notAsked: () => , 54 | loading: () => , 55 | failure: (_) => <>Oops. Something went wrong., 56 | success: (tasks) => { 57 | return 58 | }, 59 | }); 60 | } 61 | 62 | function TaskList(props: {tasks: Array}) { 63 | const taskGridElements = props.tasks.map((task) => { 64 | return ( 65 | 66 | 67 | 68 | ); 69 | }); 70 | 71 | return ( 72 | 73 | 74 | {taskGridElements} 75 | 76 | 77 | ); 78 | } 79 | 80 | 81 | function MyTask(props: {task: TaskWithTweetView}) { 82 | const { task, tweet } = props.task; 83 | return ( 84 | 85 | 86 | 87 | Reward: {formatNearAmount4(task.contractorReward)} 88 | 89 | 90 | Status: {task.status} 91 | 92 | 93 | {shortenTweetText(tweet.text)} 94 | 95 | 96 | 97 | 100 | 101 | 102 | ); 103 | } 104 | 105 | 106 | export { ListMyTasksPage }; 107 | -------------------------------------------------------------------------------- /rescript/src/bindings/NearApi.res: -------------------------------------------------------------------------------- 1 | module BrowserLocalStorageKeyStore = { 2 | type t 3 | 4 | @module("near-api-js") @new @scope("keyStores") 5 | external make: unit => t = "BrowserLocalStorageKeyStore" 6 | } 7 | 8 | // TODO: consider using @obj: https://twitter.com/tsnobip/status/1476459290701578241 9 | module NearConfig = { 10 | type t = { 11 | networkId: string, 12 | nodeUrl: string, 13 | headers: Js.Dict.t, 14 | walletUrl: option, 15 | keyStore: option, // TODO: support abstract KeyStore 16 | // signer: Signer, 17 | helperUrl: option, 18 | initialBalance: option, 19 | masterAccount: option, 20 | } 21 | 22 | let make = ( 23 | ~networkId: string, 24 | ~nodeUrl: string, 25 | ~headers: Js.Dict.t, 26 | ~walletUrl: option=?, 27 | ~keyStore: option=?, // TODO: support abstract KeyStore 28 | // ~signer: Signer=?, 29 | ~helperUrl: option=?, 30 | ~initialBalance: option=?, 31 | ~masterAccount: option=?, 32 | (), 33 | ): t => { 34 | { 35 | networkId: networkId, 36 | nodeUrl: nodeUrl, 37 | headers: headers, 38 | walletUrl: walletUrl, 39 | keyStore: keyStore, 40 | helperUrl: helperUrl, 41 | initialBalance: initialBalance, 42 | masterAccount: masterAccount, 43 | } 44 | } 45 | } 46 | 47 | module Near = { 48 | type t 49 | } 50 | 51 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/account.ts#L126 52 | // This is an abstract interface that is extended by ConnectedWalletAccount 53 | module Account = { 54 | type t 55 | } 56 | 57 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/wallet-account.ts#L280 58 | module ConnectedWalletAccount = { 59 | type t 60 | } 61 | 62 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/connect.ts#L42 63 | @module("near-api-js") 64 | external connect: NearConfig.t => Js.Promise.t = "connect" 65 | 66 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/wallet-account.ts#L60 67 | module WalletConnection = { 68 | type t 69 | 70 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/wallet-account.ts#L82 71 | @module("near-api-js") @new 72 | external make: (Near.t, option) => t = "WalletConnection" 73 | 74 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/wallet-account.ts#L267 75 | @send 76 | external account: t => ConnectedWalletAccount.t = "account" 77 | 78 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/wallet-account.ts#L105 79 | @send 80 | external isSignedIn: t => bool = "isSignedIn" 81 | 82 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/wallet-account.ts#L135 83 | @send 84 | external requestSignIn: ( 85 | t, 86 | string, // contractId 87 | string, 88 | ) => // title 89 | Js.Promise.t = "requestSignIn" 90 | } 91 | 92 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/contract.ts#L31 93 | module ContractMethods = { 94 | type t = { 95 | changeMethods: array, 96 | viewMethods: array, 97 | } 98 | } 99 | 100 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/contract.ts#L81 101 | module Contract = { 102 | type t 103 | 104 | // Origin: https://github.com/near/near-api-js/blob/v0.44.2/src/contract.ts#L90 105 | /// TODO: Find a way to use Account instead of ConnectedWalletAccount here 106 | @module("near-api-js") @new 107 | external make: (ConnectedWalletAccount.t, string, ContractMethods.t) => t = "Contract" 108 | } 109 | -------------------------------------------------------------------------------- /typescript/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import Big from 'big.js'; 2 | import { NearNetworkId } from 'near'; 3 | 4 | export type ApiErrorView = 5 | | { tag: "Internal" } 6 | | { tag: "Unauthorized" } 7 | | { tag: "Validation", content: VE } 8 | | { tag: "Malformed" } 9 | | { tag: "NotFound" }; 10 | 11 | interface CurrentUserViewRaw { 12 | name: string; 13 | profileImageUrl: string; 14 | balance: string; 15 | } 16 | 17 | interface CurrentUserView { 18 | name: string; 19 | profileImageUrl: string; 20 | balance: Big; 21 | } 22 | 23 | interface NearConfigView { 24 | contractName: string; 25 | helperUrl: string; 26 | networkId: NearNetworkId; 27 | nodeUrl: string; 28 | walletUrl: string; 29 | } 30 | 31 | interface SeedView { 32 | currentUser: CurrentUserView; 33 | nearConfig: NearConfigView; 34 | } 35 | 36 | interface SeedViewRaw { 37 | currentUser: CurrentUserViewRaw; 38 | nearConfig: NearConfigView; 39 | } 40 | 41 | // Request deposits 42 | // 43 | interface CreateRequestDepositParams { 44 | amount: string; 45 | } 46 | interface DepositRequestView { 47 | token: string; 48 | amount: string; 49 | } 50 | interface FinilizeRequestDepositParams { 51 | transactionHash: string; 52 | } 53 | 54 | // Tweet 55 | interface TweetView { 56 | id: string; 57 | text: string; 58 | authorTwitterAccountId: number, 59 | tweetCreatedAt: string, 60 | } 61 | 62 | // Retweet orders 63 | // 64 | interface MyRetweetOrderView { 65 | id: string; 66 | tweetId: string; 67 | budget: string; 68 | numberOfTasks: number; 69 | createdAt: string; 70 | updatedAt: string; 71 | } 72 | interface ExtendedRetweetOrderView { 73 | retweetOrder: MyRetweetOrderView; 74 | tweet: TweetView; 75 | details: RetweetOrderDetailsView; 76 | } 77 | interface RetweetOrderDetailsView { 78 | numberOfTasksPerformed: number; 79 | } 80 | 81 | interface CreateRetweetOrderParams { 82 | tweetId: string; 83 | budget: string; 84 | numberOfTasks: number; 85 | } 86 | 87 | // Claimable orders and tasks 88 | // 89 | interface ClaimableRetweetOrderView { 90 | id: string, 91 | reward: string, 92 | tweet: TweetView 93 | } 94 | 95 | type TaskStatusView = "Claimed" | "Abandoned" | "Performed" | "Bungled" | "Verified" | "PaidOut"; 96 | 97 | interface TaskView { 98 | id: string, 99 | orderId: string, 100 | status: TaskStatusView, 101 | contractorReward: string, 102 | } 103 | 104 | type ClaimTaskErrorView = 105 | | { tag: "UserAlreadyHasTask", content: { taskId: string } } 106 | | { tag: "OrderNotFound" } 107 | | { tag: "OrderIsNotActive" } 108 | | { tag: "OrderHasNoFreeTaskSlots" }; 109 | 110 | type CreateRetweetOrderErrorView = { 111 | tag: "NotEnoughAvailableBalance"; 112 | content: { 113 | availableBalance: string; 114 | budget: string; 115 | }; 116 | } | { 117 | tag: "ActiveOrderAlreadyExists"; 118 | } | { 119 | tag: "FailedToObtainTweet"; 120 | } | { 121 | tag: "InvalidBudget"; 122 | } | { 123 | tag: "InvalidNumberOfTasks"; 124 | }; 125 | 126 | interface TaskWithTweetView { 127 | task: TaskView, 128 | tweet: TweetView, 129 | } 130 | 131 | type CheckTaskPerformanceErrorView = "UnexpectedTaskStatus" | "TaskNotPerformed"; 132 | 133 | export type { 134 | CurrentUserView, CurrentUserViewRaw, NearConfigView, 135 | SeedView, SeedViewRaw, 136 | CreateRequestDepositParams, DepositRequestView, FinilizeRequestDepositParams, 137 | TweetView, MyRetweetOrderView, CreateRetweetOrderParams, 138 | ClaimableRetweetOrderView, TaskStatusView, TaskView, CreateRetweetOrderErrorView, ClaimTaskErrorView, 139 | TaskWithTweetView, CheckTaskPerformanceErrorView, 140 | ExtendedRetweetOrderView, RetweetOrderDetailsView, 141 | }; 142 | 143 | 144 | -------------------------------------------------------------------------------- /rescript/src/pages/tasks/ListClaimableTasksPage.res: -------------------------------------------------------------------------------- 1 | open Types 2 | 3 | let cmpByReward = (a: ClaimableRetweetOrderView.t, b: ClaimableRetweetOrderView.t): int => { 4 | if Big.gt(a.reward, b.reward) { 5 | 1 6 | } else if Big.gt(b.reward, a.reward) { 7 | -1 8 | } else { 9 | 0 10 | } 11 | } 12 | 13 | let sortByReward = (orders: array): array< 14 | ClaimableRetweetOrderView.t, 15 | > => { 16 | orders->Belt.SortArray.stableSortBy(cmpByReward)->Belt.Array.reverse 17 | } 18 | 19 | module ClaimTaskButton = { 20 | open Mui 21 | 22 | @react.component 23 | let make = (~orderId: RetweetOrderId.t) => { 24 | let history = ReactRouter.useHistory() 25 | let navigateToTask = (TaskId.TaskId(id)) => { 26 | ReactRouter.History.push(history, `/tasks/${id}`) 27 | } 28 | 29 | let logError = (err: 'e) => { 30 | Js.log(err) 31 | } 32 | 33 | let onClick = () => { 34 | Api.claimOrderTask(orderId) 35 | ->Promise.then(claimResult => { 36 | switch claimResult { 37 | | Ok(task) => navigateToTask(task.id) 38 | | Error(ClaimTaskErrorView.UserAlreadyHasTask(taskId)) => navigateToTask(taskId) 39 | | Error(err) => logError(err) 40 | }->Promise.resolve 41 | }) 42 | // TODO: catch error and show error message to user 43 | ->ignore 44 | } 45 | 46 | 47 | } 48 | } 49 | 50 | module ClaimableOrder = { 51 | open Mui 52 | 53 | @react.component 54 | let make = (~order: ClaimableRetweetOrderView.t) => { 55 | let {reward, tweet} = order 56 | let tweetUrl = tweet.id->Tweet.tweetUrl 57 | let actionsStyle = ReactDOM.Style.make(~justifyContent="space-between", ()) 58 | 59 | 60 | 61 | {React.string("Reward: " ++ Format.formatNearAmount4(reward))} 62 | 63 | 64 | {tweet.text->Tweet.shortenTweetText->React.string} 65 | 66 | 67 | 68 | 69 | {"Open Tweet"->React.string} 70 | 71 | 72 | } 73 | } 74 | 75 | module ClaimableOrderList = { 76 | open Mui 77 | 78 | @react.component 79 | let make = (~orders: array) => { 80 | let orderElements = 81 | orders 82 | ->Belt.Array.map(order => { 83 | let key = order.id->RetweetOrderId.toString 84 | 85 | }) 86 | ->React.array 87 | 88 | let boxStyle = ReactDOM.Style.make(~flexGrow="1", ()) 89 | {orderElements} 90 | } 91 | } 92 | 93 | module MainContent = { 94 | @react.component 95 | let make = () => { 96 | open AsyncData 97 | open Promise 98 | 99 | let (claimableOrders, setClaimableOrders) = React.useState(_ => NotAsked) 100 | let setOrders = orders => setClaimableOrders(_ => orders) 101 | 102 | React.useEffect0(() => { 103 | Api.getClaimableRetweetOrders() 104 | ->then(orders => setOrders(Done(Ok(sortByReward(orders))))->resolve) 105 | ->catch(err => setOrders(Done(Error(err)))->resolve) 106 | ->ignore 107 | None 108 | }) 109 | 110 | switch claimableOrders { 111 | | NotAsked 112 | | Loading => 113 | 114 | | Done(Ok(orders)) => 115 | | Done(Error(_)) => "Oops. Something went wrong"->React.string 116 | } 117 | } 118 | } 119 | 120 | @react.component 121 | let make = () => { 122 | 123 | 124 | 125 | {React.string("New tasks")} 126 | {React.string("Past tasks")} 127 | 128 | 129 | 130 | 131 | {React.string("Available Tasks")} 132 | 133 | 134 | 135 | 136 | } 137 | -------------------------------------------------------------------------------- /typescript/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './App.css'; 3 | 4 | import { HomePage } from './pages/Home'; 5 | import { DashboardPage } from './pages/Dashboard'; 6 | import { DepositPage } from './pages/Deposit'; 7 | import { NewOrderPage } from './pages/NewOrder'; 8 | import { ListMyOrdersPage } from './pages/ListMyOrders'; 9 | import { ListClaimableTasksPage } from './pages/ListClaimableTasks'; 10 | import { ListMyTasksPage } from './pages/ListMyTasks'; 11 | import { MyTaskPage } from './pages/MyTask'; 12 | import { WithdrawPage } from './pages/Withdraw'; 13 | 14 | import type { CurrentUserView, NearConfigView, SeedView } from './types'; 15 | import { ApiClient } from './apiClient'; 16 | import { setupNear } from './near'; 17 | import type { NearEnv } from './near'; 18 | import { RemoteData } from 'remoteData'; 19 | import { ApplicationBarContext } from 'components/applicationBarV2'; 20 | import { useLocalStorage } from 'hooks'; 21 | import { UserContext } from 'contexts/UserContext'; 22 | 23 | import { 24 | BrowserRouter as Router, 25 | Switch, 26 | Route, 27 | } from 'react-router-dom'; 28 | 29 | function App() { 30 | const [nearEnv, setNearEnv] = React.useState(RemoteData.newNotAsked()); 31 | const [seed, setSeed] = React.useState(RemoteData.newNotAsked()); 32 | const [isApplicationBarOpen, setIsApplicationBarOpen] = useLocalStorage("isApplicationBarOpen", true); 33 | 34 | const loadMainState = React.useCallback(() => { 35 | const apiClient = new ApiClient(); 36 | apiClient.getSeed().then((data) => { 37 | if (data !== null) { 38 | setSeed(RemoteData.newSuccess(data)); 39 | 40 | setupNear(data.nearConfig).then((nearEnv) => { 41 | setNearEnv(RemoteData.newSuccess(nearEnv)); 42 | }); 43 | } else { 44 | setSeed(RemoteData.newFailure(new Error("Not logged in"))); 45 | } 46 | }); 47 | }, []); 48 | 49 | React.useEffect(() => { 50 | loadMainState(); 51 | }, [loadMainState]); 52 | 53 | const userData = seed.mapSuccess(seedData => seedData.currentUser); 54 | 55 | return ( 56 | 57 | 58 | { 59 | seed.match({ 60 | notAsked: () => , 61 | loading: () => , 62 | failure: (err) => , 63 | success: (seed) => , 64 | }) 65 | } 66 | 67 | 68 | ); 69 | 70 | } 71 | 72 | 73 | interface AppRouterProps { 74 | currentUser: CurrentUserView, 75 | nearEnv: RemoteData, 76 | nearConfig: NearConfigView, 77 | loadMainState: () => void, 78 | } 79 | 80 | function AppRouter({currentUser, nearEnv, nearConfig, loadMainState}: AppRouterProps) { 81 | return ( 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | ); 111 | } 112 | 113 | export { App }; 114 | -------------------------------------------------------------------------------- /rescript/src/pages/tasks/MyTaskPage.res: -------------------------------------------------------------------------------- 1 | open Types 2 | 3 | let explainTaskStatus = (status: TaskStatusView.t): string => { 4 | switch status { 5 | | Claimed => "Please retweet the tweet in order to earn the reward." 6 | | Abandoned => "The task was claimed, but was not performed within reasonable time" 7 | | Performed => "You have performed the task. Once it is verified you will get your reward" 8 | | Bungled => "You had retweeted the tweet, but later undid." 9 | | Verified => "Your task is verified. Wait for the reward to come soon!" 10 | | PaidOut => "You have performed the task and got your reward." 11 | } 12 | } 13 | 14 | module TweetCard = { 15 | @react.component 16 | let make = (~tweet: TweetView.t) => { 17 | 18 | 19 | 20 | {tweet.text->Tweet.shortenTweetText->React.string} 21 | 22 | 23 | 24 | {React.string("Open Tweet")} 25 | 26 | 27 | } 28 | } 29 | 30 | module MyTask = { 31 | open AsyncData 32 | 33 | @react.component 34 | let make = (~task: TaskWithTweetView.t, ~onTaskChanged: TaskView.t => unit) => { 35 | let {task, tweet} = task 36 | let (checkState, setCheckState) = React.useState(_ => NotAsked) 37 | 38 | React.useEffect1(() => { 39 | switch checkState { 40 | | Done(Ok(Ok(t: TaskView.t))) => onTaskChanged(t) 41 | | _ => () 42 | } 43 | None 44 | }, [checkState]) 45 | 46 | let confirmAction = if task.status === Claimed { 47 | let onClick = _event => { 48 | setCheckState(_ => Loading) 49 | 50 | Api.checkTaskPerformance(task.id) 51 | ->Promise.then(task => setCheckState(_ => Done(Ok(task)))->Promise.resolve) 52 | ->Promise.catch(err => setCheckState(_ => Done(Error(err)))->Promise.resolve) 53 | ->ignore 54 | } 55 | 56 | switch checkState { 57 | | NotAsked => 58 | 59 | {"I did retweet"->React.string} 60 | 61 | | Loading => 62 | | Done(Ok(Ok(_: TaskView.t))) => 63 | 64 | {"You did it! It will take 24h-48h until you get your reward."->React.string} 65 | 66 | | Done(Ok(Error(TaskNotPerformed))) => 67 | 68 | {"It does not look like you have performed the task."->React.string} 69 | 70 | | Done(_) => 71 | 72 | {"Oops. Something went wrong. Try to reload?"->React.string} 73 | 74 | } 75 | } else { 76 | <> 77 | } 78 | 79 | <> 80 | {("Reward: " ++ Format.formatNearAmount4(task.contractorReward))->React.string} 81 |
82 | {`Status: ${TaskStatusView.display(task.status)}`->React.string} 83 | 84 | {task.status->explainTaskStatus->React.string} 85 | 86 |
87 | confirmAction 88 |
89 | 90 | 91 | } 92 | } 93 | 94 | type asyncTask = AsyncData.t 95 | module MainContent = { 96 | @react.component 97 | let make = (~taskId: TaskId.t) => { 98 | open AsyncData 99 | open Promise 100 | 101 | let (myTask: asyncTask, setMyTask) = React.useState(() => Loading) 102 | 103 | React.useEffect1(() => { 104 | Api.getMyTask(taskId)->then(task => task->(t => (_ => Done(t))->setMyTask->resolve))->ignore 105 | None 106 | }, [taskId]) 107 | 108 | let onTaskChanged = (newTask: TaskView.t) => { 109 | setMyTask(prevData => { 110 | prevData->AsyncData.map(prevTaskAndTweet => { 111 | { 112 | ...prevTaskAndTweet, 113 | task: newTask, 114 | } 115 | }) 116 | }) 117 | } 118 | 119 | switch myTask { 120 | | NotAsked | Loading => 121 | | Done(task) => 122 | } 123 | } 124 | } 125 | 126 | type urlParams = {taskId: string} 127 | @react.component 128 | let make = () => { 129 | let {taskId}: urlParams = ReactRouter.useParams() 130 | let taskId = TaskId.TaskId(taskId) 131 | 132 | 133 | 134 | 135 | {React.string("New tasks")} 136 | {React.string("Past tasks")} 137 | 138 | 139 | 140 | 141 | {React.string("Task")} 142 | 143 | 144 | 145 | 146 | } 147 | -------------------------------------------------------------------------------- /rescript/src/icons/svg/inhyped-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /typescript/src/icons/svg/inhyped-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /rescript/src/pages/orders/ListMyOrdersPage.res: -------------------------------------------------------------------------------- 1 | open Types 2 | 3 | module ProgressChip = { 4 | @react.component 5 | let make = (~performed: int, ~total: int) => { 6 | let label = `${Belt.Int.toString(performed)} / ${Belt.Int.toString(total)}`->React.string 7 | let icon = 8 | let tooltipText = 9 | `${Belt.Int.toString(performed)} out of ${Belt.Int.toString( 10 | total, 11 | )} tasks are performed`->React.string 12 | 13 | 14 | 15 | } 16 | } 17 | 18 | module MyRetweetOrder = { 19 | open Mui 20 | 21 | @react.component 22 | let make = (~extOrder: ExtendedRetweetOrderView.t) => { 23 | let {retweetOrder, tweet, details} = extOrder 24 | 25 | 26 | 27 | 28 | 29 | 31 | {("Budget: " ++ Format.formatNearAmount4(retweetOrder.budget))->React.string} 32 | 33 | 34 | 35 | 36 | 39 | 40 | 41 | 42 | 43 | {tweet.text->Tweet.shortenTweetText->React.string} 44 | 45 | 46 | 47 | {"Open Tweet"->React.string} 48 | 49 | 50 | } 51 | } 52 | 53 | module MainContent = { 54 | open AsyncData 55 | 56 | let tap = (x, f) => { 57 | f(x) 58 | x 59 | } 60 | 61 | let renderOrderList = (orders: array) => { 62 | open Mui 63 | 64 | let orderElements = 65 | orders 66 | ->Belt.Array.map(order => { 67 | let key = order.retweetOrder.id->RetweetOrderId.toString 68 | 69 | 70 | 71 | }) 72 | ->React.array 73 | 74 | 75 | {orderElements} 76 | 77 | } 78 | 79 | let cmpString = (a: string, b: string): int => { 80 | let cmpResult = Js.String.localeCompare(a, b) 81 | if cmpResult > 0.0 { 82 | 1 83 | } else if cmpResult < 0.0 { 84 | -1 85 | } else { 86 | 0 87 | } 88 | } 89 | 90 | let sortByCreatedAt = (orders: array): array< 91 | ExtendedRetweetOrderView.t, 92 | > => { 93 | let cmpByCreatedAt = (a: ExtendedRetweetOrderView.t, b: ExtendedRetweetOrderView.t) => 94 | cmpString(a.retweetOrder.createdAt, b.retweetOrder.createdAt) 95 | orders->Belt.SortArray.stableSortBy(cmpByCreatedAt) 96 | } 97 | 98 | @react.component 99 | let make = () => { 100 | let (orders, setOrders) = React.useState(_ => Loading) 101 | 102 | React.useEffect0(() => { 103 | Api.getMyRetweetOrders() 104 | ->Promise.then(orders => 105 | orders->(orders => (_ => Done(Ok(orders->sortByCreatedAt)))->setOrders->Promise.resolve) 106 | ) 107 | ->Promise.catch(err => 108 | err->tap(Js.log)->(err => (_ => Done(Error(err)))->setOrders->Promise.resolve) 109 | ) 110 | ->ignore 111 | None 112 | }) 113 | 114 | switch orders { 115 | | NotAsked 116 | | Loading => 117 | 118 | | Done(Error(_)) => <> {React.string("Ooops. Something went wront")} 119 | | Done(Ok(orders)) => renderOrderList(orders) 120 | } 121 | } 122 | } 123 | 124 | @react.component 125 | let make = () => { 126 | open Mui 127 | 128 | 129 | 130 | 131 | 145 | 146 | 147 | 148 | 149 | 150 | {React.string("My Orders")} 151 | 152 | 153 | 154 | 155 | } 156 | -------------------------------------------------------------------------------- /typescript/src/apiClient.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ApiErrorView, 3 | CurrentUserView, CurrentUserViewRaw, 4 | SeedView, SeedViewRaw, 5 | CreateRequestDepositParams, DepositRequestView, 6 | FinilizeRequestDepositParams, CreateRetweetOrderParams, 7 | ClaimableRetweetOrderView, TaskView, ClaimTaskErrorView, TaskWithTweetView, 8 | CheckTaskPerformanceErrorView, CreateRetweetOrderErrorView, ExtendedRetweetOrderView 9 | } from './types'; 10 | 11 | import type { 12 | WithdrawParams, WithdrawResponseView, WithdrawErrorView 13 | } from './types/ledger'; 14 | import { Result } from 'result'; 15 | import Big from 'big.js'; 16 | 17 | class ApiClient { 18 | getSeed(): Promise { 19 | const headers = {'Content-Type': 'application/json'}; 20 | return fetch("/api/seed", { headers }) 21 | .then(async (response) => { 22 | if (response.ok) { 23 | let seedViewRaw = await response.json(); 24 | return convertSeedViewRawToSeedView(seedViewRaw); 25 | } else { 26 | return null; 27 | } 28 | }); 29 | } 30 | 31 | createDepositRequest(params: CreateRequestDepositParams): Promise { 32 | const headers = {'Content-Type': 'application/json'}; 33 | return fetch('/api/deposit-requests', { 34 | headers, 35 | method: 'POST', 36 | body: JSON.stringify(params), 37 | }).then((response) => { 38 | if (response.ok) { 39 | return response.json() as Promise; 40 | } else { 41 | throw new Error("Failed to create DepositRequest"); 42 | } 43 | }); 44 | } 45 | 46 | finilizeDepositRequest(params: FinilizeRequestDepositParams): Promise<{}> { 47 | const headers = {'Content-Type': 'application/json'}; 48 | return fetch('/api/deposit-requests/finilize', { 49 | headers, 50 | method: 'POST', 51 | body: JSON.stringify(params), 52 | }).then((response) => { 53 | if (response.ok) { 54 | return Promise.resolve({}); 55 | } else { 56 | return Promise.reject(); 57 | } 58 | }); 59 | } 60 | 61 | createRetweetOrder(params: CreateRetweetOrderParams): Promise> { 62 | return post('/api/retweet-orders', params); 63 | } 64 | 65 | getMyRetweetOrders(): Promise> { 66 | return get("/api/retweet-orders/my"); 67 | } 68 | 69 | getClaimableRetweetOrders(): Promise> { 70 | return get("/api/retweet-orders/claimable"); 71 | } 72 | 73 | claimOrderTask(retweetOrderId: string): Promise> { 74 | return post(`/api/retweet-orders/${retweetOrderId}/claim-task`); 75 | } 76 | 77 | getMyTasks(): Promise> { 78 | return get('/api/tasks/my'); 79 | } 80 | 81 | getMyTask(taskId: string): Promise { 82 | return get(`/api/tasks/${taskId}`); 83 | } 84 | 85 | checkTaskPerformance(taskId: string): Promise> { 86 | return post(`/api/tasks/${taskId}/check-performance`); 87 | } 88 | 89 | withdraw(params: WithdrawParams): Promise> { 90 | return post('/api/withdraw', params); 91 | } 92 | } 93 | 94 | function get(path: string): Promise { 95 | const headers = {'Content-Type': 'application/json'}; 96 | return fetch(path, { headers }) 97 | .then(async (response) => { 98 | if (response.ok) { 99 | return (await response.json() as T); 100 | } else { 101 | return Promise.reject(response); 102 | } 103 | }); 104 | } 105 | 106 | function post(path: string, params: Params|undefined = undefined): Promise> { 107 | const options = { 108 | headers: {'Content-Type': 'application/json'}, 109 | method: 'POST', 110 | body: JSON.stringify(params), 111 | } 112 | return fetch(path, options) 113 | .then(async (response) => { 114 | if (response.ok) { 115 | const responsePayload = await response.json() as Response; 116 | return Result.newOk(responsePayload); 117 | } else if (response.status === 400) { 118 | const apiError = await response.json() as ApiErrorView; 119 | switch (apiError.tag) { 120 | case "Validation": 121 | return Result.newErr(apiError.content); 122 | default: 123 | return Promise.reject(response); 124 | } 125 | } else { 126 | return Promise.reject(response); 127 | } 128 | }); 129 | } 130 | 131 | function convertCurrentUserViewRawToCurrentUserView(raw: CurrentUserViewRaw): CurrentUserView { 132 | return { 133 | ...raw, 134 | balance: Big(raw.balance) 135 | }; 136 | } 137 | 138 | function convertSeedViewRawToSeedView(raw: SeedViewRaw): SeedView { 139 | return { 140 | ...raw, 141 | currentUser: convertCurrentUserViewRawToCurrentUserView(raw.currentUser) 142 | }; 143 | } 144 | 145 | 146 | export { ApiClient }; 147 | -------------------------------------------------------------------------------- /typescript/src/pages/ListMyOrders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { ExtendedRetweetOrderView } from 'types'; 4 | 5 | import { styled } from '@mui/material/styles'; 6 | import { 7 | Typography, Container, Button, 8 | Grid, Box, Card, CardContent, CardActions, Toolbar, Chip, Tooltip, 9 | } from '@mui/material'; 10 | import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; 11 | import TaskOutlinedIcon from '@mui/icons-material/TaskOutlined'; 12 | import * as _ from 'lodash'; 13 | 14 | import { ApiClient } from 'apiClient'; 15 | import { RemoteData } from 'remoteData'; 16 | import { tweetUrl, shortenTweetText } from 'utils/tweet'; 17 | import { formatNearAmount4 } from 'utils/format'; 18 | import { ApplicationBar } from 'components/applicationBarV2'; 19 | import { Link as RouterLink } from 'react-router-dom'; 20 | import { ExternalLink } from 'components/externalLink'; 21 | import { SkeletonCardList } from 'components/SkeletonCardList'; 22 | 23 | const UnstyledLink = styled(RouterLink)` 24 | text-decoration: none; 25 | 26 | &:focus, &:hover, &:visited, &:link, &:active { 27 | text-decoration: none; 28 | } 29 | `; 30 | 31 | function ListMyOrdersPage() { 32 | const content = ( 33 | <> 34 | 35 | 36 | 45 | 46 | 47 | 48 | 49 | My Orders 50 | 51 | 52 | 53 | 54 | ); 55 | 56 | return ( 57 | 61 | ); 62 | } 63 | 64 | 65 | function MainContent() { 66 | let [myOrders, setMyOrders] = React.useState(RemoteData.newNotAsked, Error>()); 67 | 68 | React.useEffect(() => { 69 | const apiClient = new ApiClient(); 70 | apiClient.getMyRetweetOrders().then((orders) => { 71 | const sortedOrders = _.sortBy(orders, (o) => o.retweetOrder.createdAt).reverse(); 72 | setMyOrders(RemoteData.newSuccess(sortedOrders)); 73 | }).catch(err => setMyOrders(RemoteData.newFailure(err))) 74 | }, []); 75 | 76 | return myOrders.match({ 77 | notAsked: () => , 78 | loading: () => , 79 | failure: (_) => <>Oops. Something went wrong., 80 | success: (orders) => { 81 | return 82 | }, 83 | }); 84 | } 85 | 86 | function OrderList(props: {orders: Array}) { 87 | const orderItems = props.orders.map((order) => { 88 | return ( 89 | 90 | 91 | 92 | ); 93 | }); 94 | 95 | return ( 96 | 97 | 98 | {orderItems} 99 | 100 | 101 | ); 102 | } 103 | 104 | 105 | function MyRetweetOrder(props: {extOrder: ExtendedRetweetOrderView}) { 106 | const { retweetOrder, tweet, details } = props.extOrder; 107 | return ( 108 | 109 | 110 | 111 | 112 | 113 | Budget: {formatNearAmount4(retweetOrder.budget)} 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | {shortenTweetText(tweet.text)} 124 | 125 | 126 | 127 | Open Tweet 128 | 129 | 130 | ); 131 | } 132 | 133 | interface ProgressChipProps { 134 | performed: number, 135 | total: number, 136 | } 137 | 138 | function ProgressChip(props: ProgressChipProps) { 139 | const { performed, total } = props; 140 | 141 | const label = `${performed} / ${total}`; 142 | const icon = ; 143 | const tooltipText = `${performed} out of ${total} tasks are performed`; 144 | return ( 145 | 146 | 147 | 148 | ); 149 | } 150 | 151 | export { ListMyOrdersPage }; 152 | -------------------------------------------------------------------------------- /rescript/src/shared/Api.res: -------------------------------------------------------------------------------- 1 | open Promise 2 | open Types 3 | 4 | module ApiErrorView = { 5 | type t<'ve> = Internal | Unauthorized | Validation('ve) | Malformed | NotFound 6 | 7 | type decoder<'a> = Js.Json.t => 'a 8 | 9 | let decode = (json: Js.Json.t, veDecoder: decoder<'ve>): t<'ve> => { 10 | switch CodecUtils.pickTag(json) { 11 | | "Internal" => Internal 12 | | "Unauthorized" => Unauthorized 13 | | "Validation" => Validation(veDecoder(CodecUtils.pickContent(json))) 14 | | "Malformed" => Malformed 15 | | "NotFound" => NotFound 16 | | _ => raise(Json.Decode.DecodeError(`Unknown ApiErrorView: ${Json.stringify(json)}`)) 17 | } 18 | } 19 | } 20 | 21 | let getMyTask = (TaskId.TaskId(id)): Promise.t => { 22 | Fetch.fetch(`/api/tasks/${id}`) 23 | ->then(Fetch.Response.json) 24 | ->then(json => json->TaskWithTweetView.decode->resolve) 25 | } 26 | 27 | let getClaimableRetweetOrders = (): Promise.t> => { 28 | Fetch.fetch(`/api/retweet-orders/claimable`) 29 | ->then(Fetch.Response.json) 30 | ->then(json => Json.Decode.array(ClaimableRetweetOrderView.decode, json)->resolve) 31 | } 32 | 33 | let getMyTasks = (): Promise.t> => { 34 | Fetch.fetch(`/api/tasks/my`) 35 | ->then(Fetch.Response.json) 36 | ->then(json => D.array(TaskWithTweetView.decode, json)->resolve) 37 | } 38 | 39 | let getMyRetweetOrders = (): Promise.t> => { 40 | Fetch.fetch(`/api/retweet-orders/my`) 41 | ->then(Fetch.Response.json) 42 | ->then(json => D.array(ExtendedRetweetOrderView.decode, json)->resolve) 43 | } 44 | 45 | let getSeed = (): Promise.t => { 46 | Fetch.fetch(`/api/seed`)->then(Fetch.Response.json)->then(json => json->SeedView.decode->resolve) 47 | } 48 | 49 | let post = ( 50 | ~path: string, 51 | ~payload: option, 52 | ~okDecoder: Js.Json.t => 'a, 53 | ~errorDecoder: Js.Json.t => 'e, 54 | ): Promise.t> => { 55 | let req = switch payload { 56 | | Some(payload) => 57 | let headers = Fetch.HeadersInit.makeWithArray([("Content-Type", "application/json")]) 58 | Fetch.RequestInit.make( 59 | ~method_=Post, 60 | ~body=Fetch.BodyInit.make(Js.Json.stringify(payload)), 61 | ~headers, 62 | (), 63 | ) 64 | | None => Fetch.RequestInit.make(~method_=Post, ()) 65 | } 66 | 67 | Fetch.fetchWithInit(path, req)->then(resp => { 68 | if Fetch.Response.ok(resp) { 69 | Fetch.Response.json(resp)->then(json => json->okDecoder->Ok->resolve) 70 | } else { 71 | let respStatus = Fetch.Response.status(resp) 72 | if respStatus === 400 { 73 | Fetch.Response.json(resp) 74 | ->then(json => json->ApiErrorView.decode(errorDecoder)->resolve) 75 | ->then(err => { 76 | switch err { 77 | | Validation(ve) => ve->Error->resolve 78 | | _ => reject(Failure("Server returned unexpected error")) 79 | } 80 | }) 81 | } else { 82 | reject(Failure("Unexpected response status")) 83 | } 84 | } 85 | }) 86 | } 87 | 88 | let checkTaskPerformance = (TaskId.TaskId(taskId)): Promise.t< 89 | result, 90 | > => { 91 | post( 92 | ~path=`/api/tasks/${taskId}/check-performance`, 93 | ~payload=None, 94 | ~okDecoder=TaskView.decode, 95 | ~errorDecoder=CheckTaskPerformanceErrorView.decode, 96 | ) 97 | } 98 | 99 | let claimOrderTask = (id: RetweetOrderId.t): Promise.t< 100 | result, 101 | > => { 102 | let RetweetOrderId.RetweetOrderId(id) = id 103 | post( 104 | ~path=`/api/retweet-orders/${id}/claim-task`, 105 | ~payload=None, 106 | ~okDecoder=TaskView.decode, 107 | ~errorDecoder=ClaimTaskErrorView.decode, 108 | ) 109 | } 110 | 111 | let createRetweetOrder = (params: CreateRetweetOrderParams.t): Promise.t< 112 | result, 113 | > => { 114 | let payload = Some(CreateRetweetOrderParams.encode(params)) 115 | post( 116 | ~path=`/api/retweet-orders`, 117 | ~payload, 118 | ~okDecoder=ExtendedRetweetOrderView.decode, 119 | ~errorDecoder=CreateRetweetOrderErrorView.decode, 120 | ) 121 | } 122 | 123 | let withdraw = (params: WithdrawParams.t): Promise.t< 124 | result, 125 | > => { 126 | let payload = Some(WithdrawParams.encode(params)) 127 | post( 128 | ~path=`/api/withdraw`, 129 | ~payload, 130 | ~okDecoder=WithdrawResponseView.decode, 131 | ~errorDecoder=WithdrawErrorView.decode, 132 | ) 133 | } 134 | 135 | let createDepositRequest = (params: CreateRequestDepositParams.t): Promise.t< 136 | DepositRequestView.t, 137 | > => { 138 | let unitDecoder = (_: Js.Json.t) => () 139 | let payload = Some(CreateRequestDepositParams.encode(params)) 140 | post( 141 | ~path=`/api/deposit-requests`, 142 | ~payload, 143 | ~okDecoder=DepositRequestView.decode, 144 | ~errorDecoder=unitDecoder, 145 | )->Promise.then(result => { 146 | switch result { 147 | | Ok(depositRequest) => Promise.resolve(depositRequest) 148 | | Error(_) => reject(Failure("Unexpected error on attempt to createDepositRequest")) 149 | } 150 | }) 151 | } 152 | 153 | let finilizeDepositRequest = (params: FinilizeRequestDepositParams.t): Promise.t< 154 | result, 155 | > => { 156 | let unitDecoder = (_: Js.Json.t) => () 157 | let payload = Some(FinilizeRequestDepositParams.encode(params)) 158 | post( 159 | ~path=`/api/deposit-requests/finilize`, 160 | ~payload, 161 | ~okDecoder=unitDecoder, 162 | ~errorDecoder=unitDecoder, 163 | ) 164 | } 165 | -------------------------------------------------------------------------------- /typescript/src/pages/ListClaimableTasks.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ClaimableRetweetOrderView, TaskView } from "types"; 3 | import { 4 | Typography, Container, Button, 5 | Grid, Box, Card, CardContent, CardActions, Toolbar, Stack, 6 | } from '@mui/material'; 7 | import { useHistory } from "react-router-dom"; 8 | import Big from 'big.js'; 9 | 10 | import { ApiClient } from 'apiClient'; 11 | import { RemoteData } from 'remoteData'; 12 | import { formatNearAmount4 } from 'utils/format'; 13 | import { tweetUrl, shortenTweetText } from 'utils/tweet'; 14 | import { ApplicationBar } from 'components/applicationBarV2'; 15 | import { Link } from "react-router-dom"; 16 | import { ExternalLink } from 'components/externalLink'; 17 | import { SkeletonCardList } from 'components/SkeletonCardList'; 18 | 19 | function ListClaimableTasksPage() { 20 | const content = ( 21 | <> 22 | 23 | 24 | New tasks 25 | Past tasks 26 | 27 | 28 | 29 | 30 | Available Tasks 31 | 32 | 33 | 34 | 35 | ) 36 | return ( 37 | 41 | ); 42 | } 43 | 44 | 45 | function MainContent() { 46 | let [claimableOrders, setClaimableOrders] = React.useState(RemoteData.newNotAsked, Error>()); 47 | 48 | React.useEffect(() => { 49 | const apiClient = new ApiClient(); 50 | apiClient.getClaimableRetweetOrders().then((claimableOrders) => { 51 | const sortedClaimableOrders = claimableOrders.sort(cmpByReward).reverse(); 52 | setClaimableOrders(RemoteData.newSuccess(sortedClaimableOrders)); 53 | }).catch(err => { 54 | console.log(err); 55 | setClaimableOrders(RemoteData.newFailure(err)); 56 | }); 57 | }, []); 58 | 59 | return claimableOrders.match({ 60 | notAsked: () => , 61 | loading: () => , 62 | failure: (_) => <>Oops. Something went wrong., 63 | success: (orders) => { 64 | return 65 | }, 66 | }); 67 | } 68 | 69 | function ClaimableOrderList(props: {claimableOrders: Array}) { 70 | const orderItems = props.claimableOrders.map((claimableOrder) => { 71 | return ( 72 | 73 | 74 | 75 | ); 76 | }); 77 | 78 | return ( 79 | 80 | 81 | {orderItems} 82 | 83 | 84 | ); 85 | } 86 | 87 | 88 | function ClaimableOrder(props: {claimableOrder: ClaimableRetweetOrderView}) { 89 | const { id, reward, tweet } = props.claimableOrder; 90 | return ( 91 | 92 | 93 | 94 | Reward: {formatNearAmount4(reward)} 95 | 96 | 97 | {shortenTweetText(tweet.text)} 98 | 99 | 100 | 101 | 102 |     103 | Open Tweet 104 | 105 | 106 | ); 107 | } 108 | 109 | function ClaimTaskButton(props: {orderId: string}) { 110 | const { orderId } = props; 111 | const history = useHistory(); 112 | 113 | const showErrorAlert = () => { 114 | alert("Ooops! An error has occurred. Maybe try to reload?"); 115 | }; 116 | const navigateToTask = (taskId: string) => { 117 | history.push(`/tasks/${taskId}`); 118 | }; 119 | 120 | const handler = () => { 121 | const apiClient = new ApiClient(); 122 | apiClient.claimOrderTask(orderId).then((taskResult) => { 123 | taskResult.match({ 124 | ok: (task: TaskView) => { 125 | navigateToTask(task.id); 126 | }, 127 | err: (error) => { 128 | switch (error.tag) { 129 | case "UserAlreadyHasTask": 130 | navigateToTask(error.content.taskId); 131 | break; 132 | default: 133 | console.log(error); 134 | showErrorAlert(); 135 | } 136 | } 137 | }); 138 | }).catch((err) => { console.log(err); showErrorAlert() }); 139 | }; 140 | 141 | return ( 142 | 145 | ); 146 | } 147 | 148 | function cmpByReward(orderA: ClaimableRetweetOrderView, orderB: ClaimableRetweetOrderView): number { 149 | const rewardA = Big(orderA.reward).toNumber(); 150 | const rewardB = Big(orderB.reward).toNumber(); 151 | if (rewardA > rewardB) { 152 | return 1; 153 | } else if (rewardA < rewardB) { 154 | return -1; 155 | } else { 156 | return 0; 157 | } 158 | } 159 | 160 | export { ListClaimableTasksPage }; 161 | -------------------------------------------------------------------------------- /typescript/src/pages/MyTask.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { TaskStatusView, TaskView, TaskWithTweetView, TweetView, CheckTaskPerformanceErrorView } from 'types'; 4 | 5 | import { 6 | Typography, Container, Button, CircularProgress, 7 | Card, CardContent, CardActions, Alert, Toolbar, Stack 8 | } from '@mui/material'; 9 | import { useParams, Link } from "react-router-dom"; 10 | 11 | import { ApplicationBar } from 'components/applicationBarV2'; 12 | import { ApiClient } from 'apiClient'; 13 | import { RemoteData } from 'remoteData'; 14 | import { tweetUrl } from 'utils/tweet'; 15 | import { Result } from 'result'; 16 | import { ExternalLink } from 'components/externalLink'; 17 | 18 | function MyTaskPage() { 19 | let { taskId } = useParams<{taskId: string}>(); 20 | 21 | const content = ( 22 | <> 23 | 24 | 25 | New tasks 26 | Past tasks 27 | 28 | 29 | 30 | 31 | Task 32 | 33 | 34 | 35 | 36 | ); 37 | return ; 38 | } 39 | 40 | function MainContent({taskId}: {taskId: string}) { 41 | let [myTask, setMyTask] = React.useState(RemoteData.newNotAsked()); 42 | 43 | React.useEffect(() => { 44 | const apiClient = new ApiClient(); 45 | apiClient.getMyTask(taskId).then((myTask) => { 46 | setMyTask(RemoteData.newSuccess(myTask)); 47 | }).catch(err => setMyTask(RemoteData.newFailure(err))) 48 | }, [taskId]); 49 | 50 | const updateTaskCallback = (task: TaskView) => { 51 | const myUpdatedTask = myTask.mapSuccess(taskWithTweet => ({ ...taskWithTweet, task })); 52 | setMyTask(myUpdatedTask); 53 | }; 54 | 55 | const renderLoading = () => { 56 | return <> 57 | 58 |

Loading the tasks...

59 | ; 60 | } 61 | 62 | return myTask.match({ 63 | notAsked: renderLoading, 64 | loading: renderLoading, 65 | failure: (_) => <>Oops. Something went wrong., 66 | success: (taskWithTweet) => { 67 | return 68 | }, 69 | }); 70 | } 71 | 72 | 73 | interface MyTaskProps { 74 | taskWithTweet: TaskWithTweetView, 75 | updateTaskCallback: (tv: TaskView) => void, 76 | } 77 | 78 | function MyTask({taskWithTweet, updateTaskCallback}: MyTaskProps) { 79 | const { task, tweet } = taskWithTweet; 80 | 81 | const [checkState, setCheckState] = 82 | React.useState(RemoteData.newNotAsked, Error>()); 83 | 84 | let confirmButton = <>; 85 | 86 | if (task.status === "Claimed") { 87 | const confirmHandler = () => { 88 | setCheckState(RemoteData.newLoading()); 89 | const apiClient = new ApiClient(); 90 | apiClient.checkTaskPerformance(task.id).then((result) => { 91 | setCheckState(RemoteData.newSuccess(result)); 92 | // call updateTaskCallback with the received task 93 | result.mapOk(updateTaskCallback); 94 | }).catch(err => setCheckState(RemoteData.newFailure(err))); 95 | }; 96 | 97 | const errorAlert = Oops. Something went wrong. Try to reload? 🤕 ; 98 | const successAlert = You did it! It will take a while until you get your reward 😀 ; 99 | const notPerformedAlert = It does not look like you have performed the task 😢; 100 | confirmButton = checkState.match({ 101 | notAsked: () => , 102 | loading: () => , 103 | failure: (_) => errorAlert, 104 | success: (result) => result.match({ 105 | ok: (task) => successAlert, 106 | err: (err) => err === "TaskNotPerformed" ? notPerformedAlert : errorAlert, 107 | }) 108 | }); 109 | } 110 | 111 | return ( 112 | <> 113 | Reward: {task.contractorReward} Ⓝ 114 |
115 | Status: {task.status} { explainTaskStatus(task.status) } 116 |
117 | { confirmButton } 118 | 119 |
120 | 121 | 122 | ); 123 | } 124 | 125 | function TweetCard({tweet}: {tweet: TweetView}) { 126 | return ( 127 | 128 | 129 | 130 | {tweet.text} 131 | 132 | 133 | 134 | Open Tweet 135 | 136 | 137 | ); 138 | } 139 | 140 | function explainTaskStatus(status: TaskStatusView): string { 141 | switch (status) { 142 | case "Claimed": return "Please retweet the tweet in order to earn the reward."; 143 | case "Abandoned": return "The task was claimed, but was not performed within reasonable time"; 144 | case "Performed": return "You have performed the task. Once it is verified you will get your reward"; 145 | case "Bungled": return "You had retweeted the tweet, but later undid."; 146 | case "Verified": return "Your task is verified. Wait for the reward to come soon!"; 147 | case "PaidOut": return "You have performed the task and got your reward."; 148 | default: throw new Error(`Unknown task status: ${status}`); 149 | } 150 | } 151 | 152 | 153 | export { MyTaskPage }; 154 | -------------------------------------------------------------------------------- /rescript/src/components/ApplicationBar.res: -------------------------------------------------------------------------------- 1 | module UnstyledLink = { 2 | @react.component 3 | let make = (~to: string, ~children: React.element) => { 4 | let style = ReactDOM.Style.make(~textDecoration="none", ()) 5 | {children} 6 | } 7 | } 8 | 9 | module MenuItems = { 10 | open Mui 11 | 12 | type menuItem = { 13 | path: string, 14 | name: string, 15 | icon: React.element, 16 | } 17 | 18 | @react.component 19 | let make = () => { 20 | let menuItems: array = [ 21 | {path: "/orders/my", name: "Orders", icon: }, 22 | {path: "/tasks/claimable", name: "Tasks", icon: }, 23 | {path: "/deposit", name: "Transactions", icon: }, 24 | ] 25 | 26 | let menuLinks = Belt.Array.map(menuItems, menuItem => { 27 | let {path, name, icon} = menuItem 28 | 29 | 30 | {icon} 31 | 32 | 33 | }) 34 | 35 | {React.array(menuLinks)} 36 | } 37 | } 38 | 39 | module UserBalance = { 40 | open Mui 41 | 42 | @react.component 43 | let make = (~amount: option) => { 44 | switch amount { 45 | | Some(balance) => 46 | 47 | {React.string(`Balance: ` ++ Format.formatNearAmount(balance))} 48 | 49 | | _ => {React.string(`Balance: ... `)} 50 | } 51 | } 52 | } 53 | 54 | module RightSide = { 55 | open Mui 56 | 57 | let asyncOptionMap = (asyncOption, f) => { 58 | asyncOption->AsyncData.map(maybeValue => { 59 | maybeValue->Belt.Option.map(f) 60 | }) 61 | } 62 | 63 | type origin = { 64 | horizontal: string, 65 | vertical: string, 66 | } 67 | 68 | @react.component 69 | let make = () => { 70 | let {user: asyncUser} = React.useContext(UserContext.context) 71 | let maybeUser = switch asyncUser { 72 | | Done(Some(user)) => Some(user) 73 | | _ => None 74 | } 75 | 76 | let amount = Belt.Option.map(maybeUser, u => u.balance) 77 | let avatarUrl = Belt.Option.map(maybeUser, u => u.profileImageUrl) 78 | 79 | let (anchorEl, setAnchorEl) = React.useState(_ => None) 80 | let isOpen = Belt.Option.isSome(anchorEl) 81 | 82 | let handleMenu = e => { 83 | let ct = ReactEvent.Mouse.currentTarget(e) 84 | setAnchorEl(_ => Some(ct)) 85 | } 86 | let handleClose = () => { 87 | setAnchorEl(_ => None) 88 | } 89 | 90 | let origin: origin = { 91 | vertical: "top", 92 | horizontal: "right", 93 | } 94 | 95 | <> 96 | 97 | 98 | 106 | 107 | 108 | {React.string("Deposit")} 109 | 110 | 111 | 112 | 113 | } 114 | } 115 | 116 | module StyledAppBar = { 117 | @module("./ApplicationBar/styledComponents") @react.component 118 | external make: (~children: React.element, ~_open: bool, ~position: string=?) => React.element = 119 | "StyledAppBar" 120 | } 121 | 122 | module StyledAppDrawer = { 123 | @module("./ApplicationBar/styledComponents") @react.component 124 | external make: ( 125 | ~children: React.element, 126 | ~_open: bool, 127 | ~anchor: string=?, 128 | ~variant: string=?, 129 | ) => React.element = "StyledAppDrawer" 130 | } 131 | 132 | module AppBar = { 133 | open Mui 134 | 135 | @react.component 136 | let make = (~isOpen: bool, ~onClick: ReactEvent.Mouse.t => unit, ~title: string) => { 137 | let iconButtonStyle = if isOpen { 138 | ReactDOM.Style.make(~marginRight="36px", ~display="none", ()) 139 | } else { 140 | ReactDOM.Style.make(~marginRight="36px", ()) 141 | } 142 | 143 | 144 | 145 | 146 | 147 | 148 | 154 | {React.string(title)} 155 | 156 | 157 | 158 | 159 | } 160 | } 161 | 162 | module AppDrawer = { 163 | open Mui 164 | 165 | @react.component 166 | let make = (~isOpen: bool, ~onClick: ReactEvent.Mouse.t => unit) => { 167 | let toolbarStyle = ReactDOM.Style.make( 168 | ~display="flex", 169 | ~alignItems="center", 170 | ~justifyContent="space-between", 171 | ~paddingLeft="1px", 172 | ~paddingRight="1px", 173 | ~backgroundColor="#FFFFFF", 174 | (), 175 | ) 176 | let boxStyle = ReactDOM.Style.make( 177 | ~display="flex", 178 | ~alignItems="center", 179 | ~justifyContent="start", 180 | (), 181 | ) 182 | 183 | 184 | 185 | 186 | 189 | {React.string("Inhyped")} 190 | 191 | 192 | 193 | 194 | 195 | } 196 | } 197 | 198 | @react.component 199 | let make = (~title: string, ~children: React.element) => { 200 | let {isOpen, setIsOpen} = React.useContext(ApplicationBarContext.context) 201 | let toggleDrawer = _ => setIsOpen(prevIsOpen => !prevIsOpen) 202 | 203 | 204 | 205 | 206 | 209 | {children} 210 | 211 | 212 | } 213 | -------------------------------------------------------------------------------- /typescript/src/pages/Deposit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Big from 'big.js'; 4 | 5 | import { 6 | Typography, Container, FormControl, FormHelperText, Button, 7 | Tooltip, CircularProgress, Grid, Toolbar, Stack, TextField, Box, Alert, AlertTitle, 8 | } from '@mui/material'; 9 | import { makeStyles } from '@mui/styles'; 10 | import { useLocation } from 'react-router-dom'; 11 | import { Link as RouterLink } from "react-router-dom"; 12 | 13 | import type { CreateRequestDepositParams } from 'types'; 14 | import type { NearEnv } from 'near'; 15 | import { RemoteData } from 'remoteData'; 16 | import { ApiClient } from 'apiClient'; 17 | import { ApplicationBar } from 'components/applicationBarV2'; 18 | import { Result } from 'result'; 19 | 20 | const useStyles = makeStyles({ 21 | paper: { 22 | display: 'flex', 23 | flexDirection: 'column', 24 | alignItems: 'center', 25 | }, 26 | }); 27 | 28 | 29 | // Take from example: https://reactrouter.com/web/example/query-parameters 30 | function useQuery(): URLSearchParams { 31 | return new URLSearchParams(useLocation().search); 32 | } 33 | 34 | 35 | interface DepositPageProps { 36 | nearEnv: RemoteData, 37 | loadMainState: () => void, 38 | } 39 | 40 | function DepositPage(props: DepositPageProps) { 41 | const { nearEnv, loadMainState } = props; 42 | const classes = useStyles(); 43 | 44 | const content = ( 45 | <> 46 | 47 | 48 | Deposit 49 | Withdraw 50 | 51 | 52 | 53 | 54 | 55 | Deposit NEAR 56 | 57 | 58 | 59 | 60 | 61 | ); 62 | 63 | return ; 64 | } 65 | 66 | interface MainContentProps { 67 | nearEnvData: RemoteData, 68 | loadMainState: () => void, 69 | } 70 | 71 | function MainContent({nearEnvData, loadMainState}: MainContentProps) { 72 | const query = useQuery(); 73 | const transactionHash = query.get('transactionHashes'); 74 | 75 | if (transactionHash) { 76 | return ; 77 | } else { 78 | // Init deposit 79 | return nearEnvData.match({ 80 | notAsked: () => , 81 | loading: () => , 82 | failure: (_) => , 83 | success: (nearEnv) => { 84 | if (nearEnv.walletAccount.isSignedIn()) { 85 | return ; 86 | } else { 87 | return 88 | } 89 | } 90 | }); 91 | } 92 | } 93 | 94 | interface FinalizeTransactionProps { 95 | transactionHash: string, 96 | loadMainState: () => void, 97 | } 98 | 99 | function FinalizeTransaction({transactionHash, loadMainState}: FinalizeTransactionProps) { 100 | let [depositRequestData, setDepositRequestData] = React.useState(RemoteData.newNotAsked<{}, Error>()); 101 | 102 | React.useEffect(() => { 103 | const apiClient = new ApiClient(); 104 | apiClient.finilizeDepositRequest({transactionHash}).then(() => { 105 | setDepositRequestData(RemoteData.newSuccess({})); 106 | loadMainState(); 107 | }); 108 | }, [transactionHash, loadMainState]); 109 | 110 | const renderLoading = () => { 111 | return <> 112 | 113 |

Almost there! We are verifying your transaction...

114 | ; 115 | } 116 | 117 | return depositRequestData.match({ 118 | notAsked: renderLoading, 119 | loading: renderLoading, 120 | failure: (_) => <>We're sorry. An error has occurred. Please contract support providing the transaction hash: ${transactionHash}, 121 | 122 | // TODO: Display proper message and transaction details. 123 | success: (_) => { 124 | return ( 125 | 126 | Success! 🎉🎉🎉 127 |

Congratulation! You successfully deposited!

128 |

Now you can create orders to promote your tweets!

129 |
130 | ); 131 | }, 132 | }); 133 | } 134 | 135 | 136 | function ConnectNearWallet({nearEnv}: {nearEnv: NearEnv}) { 137 | const handler = () => { 138 | nearEnv.walletAccount.requestSignIn( 139 | nearEnv.config.contractName, 140 | "Inhyped" 141 | ); 142 | }; 143 | 144 | return <> 145 |

To proceed please connect your NEAR account

146 | 147 | 150 | ; 151 | } 152 | 153 | 154 | interface DepositFormParams { 155 | amount: string, 156 | } 157 | 158 | interface DepositFormErrors { 159 | amount: string | null, 160 | } 161 | 162 | function defaultDepositFormParams(): DepositFormParams { 163 | return { 164 | amount: "10", 165 | }; 166 | } 167 | 168 | function defaultDepositFormErrors(): DepositFormErrors { 169 | return { 170 | amount: null, 171 | }; 172 | } 173 | 174 | function DepositForm({nearEnv}: {nearEnv: NearEnv}) { 175 | const [formParams, setFormParams] = React.useState(defaultDepositFormParams); 176 | const [formErrors, setFormErrors] = React.useState(defaultDepositFormErrors); 177 | 178 | const handleAmountChange = (event: any) => { 179 | const amount = event.target.value; 180 | setFormParams(params => ({ ...params, amount })); 181 | }; 182 | 183 | const handleClick = () => { 184 | const result = validateDepositFormParams(formParams); 185 | result.match({ 186 | ok: (validParams: CreateRequestDepositParams) => { 187 | // Create a deposit token 188 | const apiClient = new ApiClient(); 189 | apiClient.createDepositRequest(validParams).then((depositRequest) => { 190 | // SUBMIT TO NEAR 191 | const contract = nearEnv.contract; 192 | const BOATLOAD_OF_GAS = Big(3).times(10 ** 13).toFixed(); 193 | contract.deposit( 194 | // function arguments 195 | { token: depositRequest.token }, 196 | 197 | // Gas limit 198 | BOATLOAD_OF_GAS, 199 | 200 | // Deposit attached 201 | Big(depositRequest.amount).times(10 ** 24).toFixed() 202 | ).then(() => { 203 | console.log("Deposited!") 204 | }); 205 | }); 206 | }, 207 | err: (errors: DepositFormErrors) => setFormErrors(errors), 208 | }); 209 | } 210 | 211 | return ( 212 | 213 | 214 | 215 | 216 | 217 | { formErrors.amount ? 218 | {formErrors.amount} 219 | : 220 | Amount of NEAR you want to deposit 221 | } 222 | 223 | 224 | 225 | 226 | 227 | 228 | 231 | 232 | 233 | 234 | 235 | 236 | ); 237 | } 238 | 239 | function validateDepositFormParams(params: DepositFormParams): Result { 240 | const amountResult = validateAmount(params.amount); 241 | if (amountResult.isOk()) { 242 | return Result.newOk({ 243 | amount: amountResult.unwrap(), 244 | }); 245 | } else { 246 | return Result.newErr({ 247 | amount: amountResult.err(), 248 | }); 249 | } 250 | } 251 | 252 | function validateAmount(amountString: string): Result { 253 | const trimmed = amountString.trim(); 254 | 255 | if (trimmed === "") { 256 | return Result.newErr("Cannot be empty"); 257 | } 258 | 259 | let amount; 260 | try { 261 | amount = Big(amountString); 262 | } catch { 263 | return Result.newErr("Is invalid"); 264 | } 265 | 266 | // NOTE: Big comparison does not work properly so we have to fallback on number 267 | if (amount.toNumber() < 0.1) { 268 | return Result.newErr("The minimal amount is 0.1 NEAR"); 269 | } 270 | if (amount.toNumber() > 1000) { 271 | return Result.newErr("The maximum amount is 1000 NEAR"); 272 | } 273 | 274 | return Result.newOk(trimmed); 275 | } 276 | 277 | 278 | export { DepositPage }; 279 | -------------------------------------------------------------------------------- /typescript/src/components/applicationBarV2.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Box from '@mui/material/Box'; 3 | import List from '@mui/material/List'; 4 | import ListItem from '@mui/material/ListItem'; 5 | import ListItemIcon from '@mui/material/ListItemIcon'; 6 | import ListItemText from '@mui/material/ListItemText'; 7 | import { styled } from '@mui/material/styles'; 8 | 9 | import { Avatar, Menu, MenuItem } from '@mui/material'; 10 | import { Link as RouterLink } from 'react-router-dom'; 11 | 12 | 13 | import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; 14 | import AssignmentIcon from '@mui/icons-material/Assignment'; 15 | import WorkIcon from '@mui/icons-material/Work'; 16 | import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; 17 | import MenuIcon from '@mui/icons-material/Menu'; 18 | import Big from 'big.js'; 19 | 20 | import { Toolbar, IconButton, Typography, Tooltip, 21 | AppBar as MuiAppBar, 22 | Drawer as MuiDrawer, 23 | AppBarProps as MuiAppBarProps , 24 | } from '@mui/material'; 25 | 26 | import { InhypedIcon } from 'icons/InhypedIcon'; 27 | import { formatNearAmount, formatNearAmount4 } from 'utils/format'; 28 | import { UserContext } from 'contexts/UserContext'; 29 | import { RemoteData } from 'remoteData'; 30 | 31 | 32 | const ApplicationBarContext = React.createContext({ 33 | isOpen: true, 34 | setIsOpen: (val: boolean) => {}, 35 | }); 36 | 37 | 38 | interface ApplicationProps { 39 | content: React.ReactNode, 40 | title: string, 41 | } 42 | 43 | function ApplicationBar(props: ApplicationProps) { 44 | const { isOpen, setIsOpen } = React.useContext(ApplicationBarContext); 45 | const toggleDrawer = () => setIsOpen(!isOpen); 46 | 47 | return ( 48 | 49 | 50 | {/* AppBar */} 51 | 52 | 53 | 58 | 68 | 69 | 70 | 71 | 78 | {props.title} 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | {/* Drawer*/} 87 | 88 | 89 | 98 | 99 | 100 | Inhyped 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | {/* Main content */} 111 | 112 | 120 | 121 | {props.content} 122 | 123 | 124 | 125 | ); 126 | } 127 | 128 | const UnstyledLink = styled(RouterLink)` 129 | text-decoration: none; 130 | 131 | &:focus, &:hover, &:visited, &:link, &:active { 132 | text-decoration: none; 133 | } 134 | `; 135 | 136 | 137 | function MenuItems() { 138 | return ( 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | ); 169 | } 170 | 171 | const drawerWidth: number = 240; 172 | 173 | 174 | const AppDrawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== 'open' })( 175 | ({ theme, open }) => ({ 176 | '& .MuiDrawer-paper': { 177 | position: 'relative', 178 | whiteSpace: 'nowrap', 179 | width: drawerWidth, 180 | transition: theme.transitions.create('width', { 181 | easing: theme.transitions.easing.sharp, 182 | duration: theme.transitions.duration.enteringScreen, 183 | }), 184 | boxSizing: 'border-box', 185 | ...(!open && { 186 | overflowX: 'hidden', 187 | transition: theme.transitions.create('width', { 188 | easing: theme.transitions.easing.sharp, 189 | duration: theme.transitions.duration.leavingScreen, 190 | }), 191 | width: theme.spacing(7), 192 | [theme.breakpoints.up('sm')]: { 193 | width: theme.spacing(9), 194 | }, 195 | }), 196 | }, 197 | }), 198 | ); 199 | 200 | 201 | interface StyledAppBarProps extends MuiAppBarProps { 202 | open?: boolean; 203 | } 204 | 205 | const StyledAppBar = styled(MuiAppBar, { 206 | shouldForwardProp: (prop) => prop !== 'open', 207 | })(({ theme, open }) => ({ 208 | zIndex: theme.zIndex.drawer + 1, 209 | transition: theme.transitions.create(['width', 'margin'], { 210 | easing: theme.transitions.easing.sharp, 211 | duration: theme.transitions.duration.leavingScreen, 212 | }), 213 | ...(open && { 214 | marginLeft: drawerWidth, 215 | width: `calc(100% - ${drawerWidth}px)`, 216 | transition: theme.transitions.create(['width', 'margin'], { 217 | easing: theme.transitions.easing.sharp, 218 | duration: theme.transitions.duration.enteringScreen, 219 | }), 220 | }), 221 | })); 222 | 223 | 224 | 225 | function UserBalance({amountData}: {amountData: RemoteData}) { 226 | const loadingBalance = Balance: ... ; 227 | 228 | return amountData.match({ 229 | success: amount => { 230 | return ( 231 | 232 | Balance: {formatNearAmount(amount)} 233 | 234 | ); 235 | }, 236 | notAsked: () => loadingBalance, 237 | loading: () => loadingBalance, 238 | failure: (_) => loadingBalance, 239 | }); 240 | } 241 | 242 | function RightSide() { 243 | const userData = React.useContext(UserContext); 244 | const amountData = userData.mapSuccess(u => u.balance); 245 | const avatarUrl = userData.match({ 246 | success: u => u.profileImageUrl, 247 | notAsked: () => undefined, 248 | loading: () => undefined, 249 | failure: () => undefined, 250 | }); 251 | 252 | const [anchorEl, setAnchorEl] = React.useState(null); 253 | const open = Boolean(anchorEl); 254 | 255 | const handleMenu = (event: React.MouseEvent) => { 256 | setAnchorEl(event.currentTarget); 257 | }; 258 | 259 | const handleClose = () => { 260 | setAnchorEl(null); 261 | }; 262 | 263 | 264 | return ( 265 | <> 266 | 267 | 274 | 275 | 276 | 277 | 278 | 293 | Deposit 294 | 295 | 296 | ); 297 | } 298 | 299 | export { ApplicationBar, ApplicationBarContext}; 300 | -------------------------------------------------------------------------------- /rescript/src/pages/transactions/DepositPage.res: -------------------------------------------------------------------------------- 1 | type formParams = {amount: string} 2 | 3 | type formErrors = {amount: option} 4 | 5 | let emptyFormParams = (): formParams => { 6 | { 7 | amount: "", 8 | } 9 | } 10 | 11 | let emptyformErrors = (): formErrors => { 12 | { 13 | amount: None, 14 | } 15 | } 16 | 17 | let validateAmount = (amount: string): result => { 18 | switch Big.parse(amount) { 19 | | Some(amount) => 20 | if Big.gt(amount, Big.fromString("1000")) { 21 | Error("The maximum amount is 1000 NEAR") 22 | } else if Big.lt(amount, Big.fromString("0.01")) { 23 | Error("The minimal amount is 0.1 NEAR") 24 | } else { 25 | Ok(amount) 26 | } 27 | | None => Error("Invalid") 28 | } 29 | } 30 | 31 | let validate = (formParams: formParams): result => { 32 | let {amount} = formParams 33 | let amountResult = validateAmount(amount) 34 | switch amountResult { 35 | | Ok(amount) => Ok({amount: amount}) 36 | | Error(amountError) => Error({amount: Some(amountError)}) 37 | } 38 | } 39 | 40 | // NEAR 41 | // 42 | type nearContext = { 43 | config: Types.NearConfigView.t, 44 | near: NearApi.Near.t, 45 | walletConnection: NearApi.WalletConnection.t, 46 | contract: InhypedContract.t, 47 | } 48 | 49 | let setupNearContext = (config: Types.NearConfigView.t): Promise.t => { 50 | let {contractName, helperUrl, networkId, nodeUrl, walletUrl} = config 51 | let keyStore = NearApi.BrowserLocalStorageKeyStore.make() 52 | let conf = NearApi.NearConfig.make( 53 | ~helperUrl, 54 | ~networkId=Types.NearNetworkId.toString(networkId), 55 | ~walletUrl, 56 | ~nodeUrl, 57 | ~keyStore, 58 | ~headers=Js.Dict.fromArray([]), 59 | (), 60 | ) 61 | 62 | NearApi.connect(conf)->Promise.then(near => { 63 | let walletConnection = NearApi.WalletConnection.make(near, None) 64 | let account = NearApi.WalletConnection.account(walletConnection) 65 | let contract = InhypedContract.make(account, contractName) 66 | let context = { 67 | config: config, 68 | near: near, 69 | walletConnection: walletConnection, 70 | contract: contract, 71 | } 72 | Promise.resolve(context) 73 | }) 74 | } 75 | 76 | let getQueryParam = (paramName: string): option => { 77 | let searchParamsStr = { 78 | open Webapi.Dom 79 | window->Window.location->Location.search 80 | } 81 | let searchParams = Webapi.Url.URLSearchParams.make(searchParamsStr) 82 | // TODO: swap (paramName, searchParams) once a new version of bs-webapi is released 83 | Webapi.Url.URLSearchParams.get(paramName, searchParams) 84 | } 85 | 86 | module ConnectNearWallet = { 87 | @react.component 88 | let make = (~nearContext: nearContext) => { 89 | let handler = () => { 90 | NearApi.WalletConnection.requestSignIn( 91 | nearContext.walletConnection, 92 | nearContext.config.contractName, 93 | "Inhyped", 94 | )->ignore 95 | } 96 | 97 | <> 98 |

{React.string("To proceed please connect your NEAR account")}

99 | 100 | {React.string("Connect NEAR account")} 101 | 102 | 103 | } 104 | } 105 | 106 | module DepositForm = { 107 | open Mui 108 | 109 | @react.component 110 | let make = (~nearContext: nearContext) => { 111 | let (formParams, setFormParams) = React.useState(emptyFormParams) 112 | let (formErrors, setFormErrors) = React.useState(emptyformErrors) 113 | 114 | let handleAmountChange = event => { 115 | let amount = event.target.value 116 | setFormParams(_ => {amount: amount}) 117 | } 118 | 119 | let helperTextOrError = (helperText: string, error: option) => { 120 | switch error { 121 | | Some(errMsg) => {React.string(errMsg)} 122 | | None => {React.string(helperText)} 123 | } 124 | } 125 | 126 | let handleClick = () => { 127 | switch validate(formParams) { 128 | | Ok(validParams) => 129 | Api.createDepositRequest(validParams) 130 | ->Promise.then(depositRequest => { 131 | let amountInYoctoNear = Big.times( 132 | depositRequest.amount, 133 | Big.fromString("1000000000000000000000000"), 134 | ) 135 | InhypedContract.deposit( 136 | nearContext.contract, 137 | ~token=depositRequest.token, 138 | ~amount=Big.toFixed(amountInYoctoNear, 0), 139 | ) 140 | }) 141 | ->ignore 142 | | Error(errors) => setFormErrors(_ => errors) 143 | } 144 | } 145 | 146 | 147 | 148 | 149 | 150 | 159 | {helperTextOrError(`Amount of Ⓝ you want to deposit`, formErrors.amount)} 160 | 161 | 162 | 163 | 164 | 168 | 171 | 172 | 173 | 174 | 175 | 176 | } 177 | } 178 | 179 | module FinalizeTransaction = { 180 | open Types 181 | open AsyncData 182 | open Mui 183 | 184 | @react.component 185 | let make = (~transactionHash: NearTransactionHash.t) => { 186 | let {reloadUser} = React.useContext(UserContext.context) 187 | let (depositState, setDepositState) = React.useState(_ => NotAsked) 188 | 189 | React.useEffect2(() => { 190 | Api.finilizeDepositRequest({transactionHash: transactionHash}) 191 | ->Promise.then(_ => { 192 | setDepositState(_ => Done(Ok())) 193 | reloadUser() 194 | Promise.resolve() 195 | }) 196 | ->Promise.catch(err => { 197 | Js.Console.error2("Error on attempt to finalize deposit transaction: ", err) 198 | setDepositState(_ => Done(Error())) 199 | Promise.resolve() 200 | }) 201 | ->ignore 202 | None 203 | }, (transactionHash, reloadUser)) 204 | 205 | switch depositState { 206 | | NotAsked 207 | | Loading => <> 208 | 209 |

{React.string(`Almost there! We are verifying your transaction... `)}

210 | 211 | | Done(Error(_)) => 212 | // TODO: Add links to telegram chats, support email, etc.. 213 | let hash = NearTransactionHash.toString(transactionHash) 214 | React.string( 215 | `We're sorry. An error has occurred. Please contract support providing the transaction hash: ${hash}`, 216 | ) 217 | | Done(Ok(_)) => 218 | 219 | {React.string(`Success! 🎉🎉🎉 `)} 220 |

{React.string(`Congratulation! You successfully deposited!`)}

221 |

222 | {React.string(`Now you can `)} 223 | 224 | {React.string(`create orders to promote your tweets!`)} 225 | 226 |

227 |
228 | } 229 | } 230 | } 231 | 232 | module MainContent = { 233 | @react.component 234 | let make = (~nearContext: nearContext) => { 235 | let transactionHash = 236 | getQueryParam("transactionHashes")->Belt.Option.map(Types.NearTransactionHash.fromString) 237 | 238 | switch transactionHash { 239 | | Some(transactionHash) => 240 | | None => 241 | if NearApi.WalletConnection.isSignedIn(nearContext.walletConnection) { 242 | 243 | } else { 244 | 245 | } 246 | } 247 | } 248 | } 249 | 250 | module MainWithNearSetup = { 251 | open AsyncData 252 | open Mui 253 | 254 | @react.component 255 | let make = (~nearConfig: Types.NearConfigView.t) => { 256 | let (nearContext, setNearContext) = React.useState(_ => Loading) 257 | 258 | React.useEffect0(() => { 259 | setupNearContext(nearConfig) 260 | ->Promise.then(ctx => { 261 | setNearContext(_ => Done(Ok(ctx))) 262 | Promise.resolve() 263 | }) 264 | ->Promise.catch(err => { 265 | Js.Console.error(err) 266 | setNearContext(_ => Done(Error(err))) 267 | Promise.resolve() 268 | }) 269 | ->ignore 270 | None 271 | }) 272 | 273 | switch nearContext { 274 | | NotAsked 275 | | Loading => 276 | 277 | | Done(Ok(context)) => 278 | | Done(Error(_)) => 279 | 280 | {React.string("Error")} 281 | {React.string("Could not connect to the smart contract.")} 282 | 283 | } 284 | } 285 | } 286 | 287 | @react.component 288 | let make = (~nearConfig: Types.NearConfigView.t) => { 289 | open Mui 290 | 291 | 292 | 293 | 294 | {React.string("Deposit")} 295 | {React.string("Withdraw")} 296 | 297 | 298 | 299 | 301 | 302 | {React.string("Deposit NEAR")} 303 | 304 | 305 | 306 | 307 | 308 | } 309 | -------------------------------------------------------------------------------- /rescript/src/pages/orders/NewOrderPage.res: -------------------------------------------------------------------------------- 1 | let parseTweetId = (tweetUrl: string): option => { 2 | let regex = %re("/https:\/\/(?:mobile\.)?twitter\.com\/.*\/status\/(\d+)/i") 3 | Js.Re.exec_(regex, tweetUrl)->Belt.Option.flatMap(result => { 4 | Js.Re.captures(result)->( 5 | matches => matches[1]->Js.Nullable.toOption->Belt.Option.map(Types.TweetId.fromString) 6 | ) 7 | }) 8 | } 9 | 10 | let parseInt = (value: string): option => { 11 | Belt.Int.fromString(value) 12 | } 13 | 14 | type formParams = { 15 | tweetUrl: string, 16 | budget: string, 17 | numberOfTasks: string, 18 | taskCost: option, 19 | } 20 | 21 | type formErrors = { 22 | tweetUrl: option, 23 | budget: option, 24 | numberOfTasks: option, 25 | taskCost: option, 26 | } 27 | 28 | let emptyFormParams = (): formParams => { 29 | { 30 | tweetUrl: "", 31 | budget: "", 32 | numberOfTasks: "", 33 | taskCost: None, 34 | } 35 | } 36 | 37 | let emptyFormErrors = (): formErrors => { 38 | tweetUrl: None, 39 | budget: None, 40 | numberOfTasks: None, 41 | taskCost: None, 42 | } 43 | 44 | let validateTweetUrl = (tweetUrl: string): result => { 45 | switch parseTweetId(tweetUrl) { 46 | | Some(tweetId) => Ok(tweetId) 47 | | None => Error("Tweet URL is invalid") 48 | } 49 | } 50 | 51 | let validateBudget = (budget: string): result => { 52 | switch Big.parse(budget) { 53 | | Some(budget) => 54 | if Big.gt(budget, Big.fromString("1000")) { 55 | Error("Uh. Is it not too much? Take it easy.") 56 | } else if Big.lt(budget, Big.fromString("0.01")) { 57 | Error("Budget must be greater than 0.01 NEAR") 58 | } else { 59 | Ok(budget) 60 | } 61 | | None => Error("Invalid") 62 | } 63 | } 64 | 65 | let validateNumberOfTasks = (value: string): result => { 66 | switch parseInt(value) { 67 | | None => Error("Invalid") 68 | | Some(number) => 69 | if number < 1 { 70 | Error("Must not be less than 1") 71 | } else if number > 10_000 { 72 | Error("Sorry. 10K is maximum at the moment") 73 | } else { 74 | Ok(number) 75 | } 76 | } 77 | } 78 | 79 | let validateTaskCost = (taskCost: option): result => { 80 | switch taskCost { 81 | | Some(cost) => 82 | if Big.lt(cost, Big.fromString("0.001")) { 83 | Error("Can not be less than 0.001 Ⓝ. Please adjust Budget or Number of retweets.") 84 | } else { 85 | Ok() 86 | } 87 | | None => Ok() 88 | } 89 | } 90 | 91 | let getError = (result: result<'a, 'e>): option<'e> => { 92 | switch result { 93 | | Ok(_) => None 94 | | Error(err) => Some(err) 95 | } 96 | } 97 | 98 | let validate = (params: formParams): result => { 99 | let {tweetUrl, budget, numberOfTasks, taskCost} = params 100 | 101 | let tweetUrlRes = validateTweetUrl(tweetUrl) 102 | let budgetRes = validateBudget(budget) 103 | let numberOfTasksRes = validateNumberOfTasks(numberOfTasks) 104 | let taskCostRes = validateTaskCost(taskCost) 105 | 106 | switch (tweetUrlRes, budgetRes, numberOfTasksRes, taskCostRes) { 107 | | (Ok(tweetId), Ok(budget), Ok(numberOfTasks), Ok()) => 108 | Ok({ 109 | tweetId: tweetId, 110 | budget: budget, 111 | numberOfTasks: numberOfTasks, 112 | }) 113 | | _ => 114 | Error({ 115 | tweetUrl: getError(tweetUrlRes), 116 | budget: getError(budgetRes), 117 | numberOfTasks: getError(numberOfTasksRes), 118 | taskCost: getError(taskCostRes), 119 | }) 120 | } 121 | } 122 | 123 | let convertCreateOrderErrorToFormErrors = ( 124 | err: Types.CreateRetweetOrderErrorView.t, 125 | ): formErrors => { 126 | open Types.CreateRetweetOrderErrorView 127 | switch err { 128 | | NotEnoughAvailableBalance({budget: _, availableBalance}) => { 129 | let msg = `You have only ${Format.formatNearAmount4(availableBalance)} available` 130 | {...emptyFormErrors(), budget: Some(msg)} 131 | } 132 | | InvalidBudget => {...emptyFormErrors(), budget: Some("Invalid")} 133 | | ActiveOrderAlreadyExists => { 134 | ...emptyFormErrors(), 135 | tweetUrl: Some("An active order with this tweet already exist"), 136 | } 137 | | FailedToObtainTweet => { 138 | ...emptyFormErrors(), 139 | tweetUrl: Some("It looks like the tweet does not exist or not available for public"), 140 | } 141 | | InvalidNumberOfTasks => {...emptyFormErrors(), numberOfTasks: Some("Invalid")} 142 | } 143 | } 144 | 145 | module OrderForm = { 146 | open Mui 147 | 148 | @react.component 149 | let make = () => { 150 | let {reloadUser} = React.useContext(UserContext.context) 151 | let (formParams, setFormParams) = React.useState(emptyFormParams) 152 | let (formErrors, setFormErrors) = React.useState(emptyFormErrors) 153 | 154 | // Set taskCost when budget or numberOfTasks change 155 | React.useEffect2(() => { 156 | let taskCost = switch (Big.parse(formParams.budget), parseInt(formParams.numberOfTasks)) { 157 | | (Some(budget), Some(numberOfTasks)) => { 158 | let number = Big.fromInt(numberOfTasks) 159 | let cost = Big.div(budget, number) 160 | Some(cost) 161 | } 162 | | _ => None 163 | } 164 | setFormParams(oldParams => {...oldParams, taskCost: taskCost}) 165 | 166 | None 167 | }, (formParams.budget, formParams.numberOfTasks)) 168 | 169 | let history = ReactRouter.useHistory() 170 | let navigateTo = (path: string) => { 171 | ReactRouter.History.push(history, path) 172 | } 173 | 174 | let handleTweetUrlChange = (event: Mui.changeEvent) => { 175 | let tweetUrl = event.target.value 176 | setFormParams(oldParams => {...oldParams, tweetUrl: tweetUrl}) 177 | } 178 | 179 | let handleNumberOfTasksChange = (event: Mui.changeEvent) => { 180 | let numberOfTasks = event.target.value 181 | setFormParams(oldParams => {...oldParams, numberOfTasks: numberOfTasks}) 182 | } 183 | 184 | let handleBudgetChange = (event: Mui.changeEvent) => { 185 | let budget = event.target.value 186 | setFormParams(oldParams => {...oldParams, budget: budget}) 187 | } 188 | 189 | let clickHandler = () => { 190 | switch validate(formParams) { 191 | | Ok(validParams) => 192 | Api.createRetweetOrder(validParams) 193 | ->Promise.then(result => { 194 | switch result { 195 | | Ok(_) => { 196 | reloadUser() 197 | navigateTo("/orders/my") 198 | } 199 | | Error(error) => { 200 | let errors = convertCreateOrderErrorToFormErrors(error) 201 | setFormErrors(_ => errors) 202 | } 203 | } 204 | Promise.resolve() 205 | }) 206 | ->ignore 207 | | Error(errors) => setFormErrors(_ => errors) 208 | } 209 | } 210 | 211 | let helperTextOrError = (helperText: string, error: option) => { 212 | switch error { 213 | | Some(errMsg) => {React.string(errMsg)} 214 | | None => {React.string(helperText)} 215 | } 216 | } 217 | 218 | let costToString = (taskCost: option): string => { 219 | switch taskCost { 220 | | Some(cost) => Format.formatNearAmount4(cost) 221 | | None => "" 222 | } 223 | } 224 | 225 | 226 | 227 | 228 | 229 | 237 | {helperTextOrError("URL of Tweet you want to promote", formErrors.tweetUrl)} 238 | 239 | 240 | 241 | 242 | 250 | {helperTextOrError("Maximum budget you want to spend", formErrors.budget)} 251 | 252 | 253 | 254 | 255 | 263 | {helperTextOrError( 264 | "Number of retweets you want to get for the given budget", 265 | formErrors.numberOfTasks, 266 | )} 267 | 268 | 269 | 270 | 271 | 278 | {helperTextOrError( 279 | "Cost of a single retweet. Calculated automatically based on Budget and Number of Retweets", 280 | formErrors.taskCost, 281 | )} 282 | 283 | 284 | 285 | 286 | 289 | 290 | 291 | 292 | 293 | } 294 | } 295 | 296 | @react.component 297 | let make = () => { 298 | open Mui 299 | 300 | 301 | 302 | 303 | 304 | {React.string("Create retweet order")} 305 | 306 | 307 | 308 | 309 | } 310 | -------------------------------------------------------------------------------- /rescript/src/bindings/Mui.res: -------------------------------------------------------------------------------- 1 | module SvgIcon = { 2 | type fontSize = [#inherit | #medium | #large | #small] 3 | 4 | @module("@mui/material") @react.component 5 | external make: ( 6 | ~children: React.element, 7 | ~component: string=?, 8 | ~sx: ReactDOM.Style.t=?, 9 | ~fontSize: fontSize=?, 10 | ) => React.element = "SvgIcon" 11 | } 12 | 13 | module LaunchIcon = { 14 | type fontSize = [#inherit | #medium | #large | #small] 15 | 16 | @module("@mui/icons-material/Launch") @react.component 17 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 18 | } 19 | 20 | module AddCircleOutlineIcon = { 21 | type fontSize = [#inherit | #medium | #large | #small] 22 | 23 | @module("@mui/icons-material/AddCircleOutline") @react.component 24 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 25 | } 26 | 27 | module TaskOutlinedIcon = { 28 | type fontSize = [#inherit | #medium | #large | #small] 29 | 30 | @module("@mui/icons-material/TaskOutlined") @react.component 31 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 32 | } 33 | 34 | module WorkIcon = { 35 | type fontSize = [#inherit | #medium | #large | #small] 36 | 37 | @module("@mui/icons-material/Work") @react.component 38 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 39 | } 40 | 41 | module AssignmentIcon = { 42 | type fontSize = [#inherit | #medium | #large | #small] 43 | 44 | @module("@mui/icons-material/Assignment") @react.component 45 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 46 | } 47 | 48 | module AccountBalanceWalletIcon = { 49 | type fontSize = [#inherit | #medium | #large | #small] 50 | 51 | @module("@mui/icons-material/AccountBalanceWallet") @react.component 52 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 53 | } 54 | 55 | module MenuIcon = { 56 | type fontSize = [#inherit | #medium | #large | #small] 57 | 58 | @module("@mui/icons-material/Menu") @react.component 59 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 60 | } 61 | 62 | module ChevronLeftIcon = { 63 | type fontSize = [#inherit | #medium | #large | #small] 64 | 65 | @module("@mui/icons-material/ChevronLeft") @react.component 66 | external make: (~fontSize: fontSize=?, ~sx: ReactDOM.Style.t=?) => React.element = "default" 67 | } 68 | 69 | module Link = { 70 | type underline = [#none | #hover | #always] 71 | 72 | @module("@mui/material") @react.component 73 | external make: ( 74 | ~href: string, 75 | ~target: string=?, 76 | ~rel: string=?, 77 | ~underline: underline=?, 78 | ~children: React.element, 79 | ) => React.element = "Link" 80 | } 81 | 82 | module Skeleton = { 83 | type variant = [#text | #rectangular | #circular] 84 | 85 | @module("@mui/material") @react.component 86 | external make: (~variant: variant=?, ~height: int=?) => React.element = "Skeleton" 87 | } 88 | 89 | module Grid = { 90 | @module("@mui/material") @react.component 91 | external make: ( 92 | ~children: React.element, 93 | ~style: ReactDOM.Style.t=?, 94 | ~item: bool=?, 95 | ~container: bool=?, 96 | ~spacing: int=?, 97 | ~xs: int=?, 98 | ~md: int=?, 99 | ~lg: int=?, 100 | ~key: string=?, 101 | ~justifyContent: string=?, 102 | ~direction: string=?, 103 | ~alignItems: string=?, 104 | ) => React.element = "Grid" 105 | } 106 | 107 | module Box = { 108 | @module("@mui/material") @react.component 109 | external make: ( 110 | ~children: React.element, 111 | ~component: string=?, 112 | ~sx: ReactDOM.Style.t=?, 113 | ~flexGrow: int=?, 114 | ) => React.element = "Box" 115 | } 116 | 117 | module Container = { 118 | type breakpoint = [#xs | #sm | #md | #lg | #xl] 119 | 120 | @module("@mui/material") @react.component 121 | external make: (~children: React.element, ~maxWidth: breakpoint=?) => React.element = "Container" 122 | } 123 | 124 | module Card = { 125 | @module("@mui/material") @react.component 126 | external make: (~children: React.element, ~style: ReactDOM.Style.t=?) => React.element = "Card" 127 | } 128 | 129 | module CardContent = { 130 | @module("@mui/material") @react.component 131 | external make: (~children: React.element) => React.element = "CardContent" 132 | } 133 | 134 | module CardActions = { 135 | @module("@mui/material") @react.component 136 | external make: (~children: React.element, ~style: ReactDOM.Style.t=?) => React.element = 137 | "CardActions" 138 | } 139 | 140 | module Typography = { 141 | type variant = [ 142 | | #button 143 | | #caption 144 | | #h1 145 | | #h2 146 | | #h3 147 | | #h4 148 | | #h5 149 | | #h6 150 | | #inherit 151 | | #subtitle1 152 | | #subtitle2 153 | | #body1 154 | | #body2 155 | | #overline 156 | ] 157 | type component = [#p | #span | #div | #h1 | #h2 | #h3 | #h4 | #h5 | #h6 | #h7] 158 | type align = [#inherit | #left | #center | #right | #justify] 159 | type color = [ 160 | | #"text.secondary" 161 | | #"text.primary" 162 | | #textPrimary 163 | | #textSecondary 164 | | #inherit 165 | | #black 166 | ] 167 | 168 | @module("@mui/material") @react.component 169 | external make: ( 170 | ~children: React.element, 171 | ~style: ReactDOM.Style.t=?, 172 | ~sx: ReactDOM.Style.t=?, 173 | ~variant: variant=?, 174 | ~component: component=?, 175 | ~align: align=?, 176 | ~gutterBottom: bool=?, 177 | ~color: color=?, 178 | ~paragraph: bool=?, 179 | ~noWrap: bool=?, 180 | ) => React.element = "Typography" 181 | } 182 | 183 | module Toolbar = { 184 | @module("@mui/material") @react.component 185 | external make: (~children: React.element=?, ~sx: ReactDOM.Style.t=?) => React.element = "Toolbar" 186 | } 187 | 188 | module Stack = { 189 | @module("@mui/material") @react.component 190 | external make: ( 191 | ~children: React.element, 192 | ~direction: string=?, 193 | ~spacing: int=?, 194 | ) => React.element = "Stack" 195 | } 196 | 197 | module CircularProgress = { 198 | @module("@mui/material") @react.component 199 | external make: (~disableShrink: bool=?) => React.element = "CircularProgress" 200 | } 201 | 202 | module Button = { 203 | type color = [#primary | #secondary] 204 | 205 | @module("@mui/material") @react.component 206 | external make: ( 207 | ~children: React.element, 208 | ~onClick: 'event => unit=?, 209 | ~size: [#small | #medium | #large]=?, 210 | ~variant: [#contained | #outlined | #text]=?, 211 | ~color: color=?, 212 | ) => React.element = "Button" 213 | } 214 | 215 | module LoadingButton = { 216 | type color = [#primary | #secondary] 217 | 218 | @module("@mui/lab") @react.component 219 | external make: ( 220 | ~children: React.element, 221 | ~onClick: 'event => unit=?, 222 | ~size: [#small | #medium | #large]=?, 223 | ~variant: [#contained | #outlined | #text]=?, 224 | ~color: color=?, 225 | ~disabled: bool=?, 226 | ~loading: bool=?, 227 | ) => React.element = "LoadingButton" 228 | } 229 | 230 | module Alert = { 231 | @module("@mui/material") @react.component 232 | external make: ( 233 | ~children: React.element, 234 | ~severity: [#error | #info | #success | #warning]=?, 235 | ) => React.element = "Alert" 236 | } 237 | 238 | module AlertTitle = { 239 | @module("@mui/material") @react.component 240 | external make: (~children: React.element) => React.element = "AlertTitle" 241 | } 242 | 243 | module Chip = { 244 | @module("@mui/material") @react.component 245 | external make: ( 246 | ~label: React.element, 247 | ~size: [#small | #medium]=?, 248 | ~icon: React.element=?, 249 | ) => React.element = "Chip" 250 | } 251 | 252 | module Tooltip = { 253 | @module("@mui/material") @react.component 254 | external make: ( 255 | ~children: React.element, 256 | ~arrow: bool=?, 257 | ~title: React.element=?, 258 | ) => React.element = "Tooltip" 259 | } 260 | 261 | module FormControl = { 262 | @module("@mui/material") @react.component 263 | external make: (~children: React.element, ~fullWidth: bool=?, ~error: bool=?) => React.element = 264 | "FormControl" 265 | } 266 | 267 | // In TS: it would be React.ChangeEvent 268 | type changeEventTarget = {value: string} 269 | type changeEvent = {target: changeEventTarget} 270 | 271 | module TextField = { 272 | @module("@mui/material") @react.component 273 | external make: ( 274 | ~label: string, 275 | ~value: string=?, 276 | ~onChange: changeEvent => unit=?, 277 | ~fullWidth: bool=?, 278 | ~required: bool=?, 279 | ~id: string=?, 280 | ~disabled: bool=?, 281 | ~error: bool=?, 282 | ) => React.element = "TextField" 283 | } 284 | 285 | module FormHelperText = { 286 | @module("@mui/material") @react.component 287 | external make: (~children: React.element) => React.element = "FormHelperText" 288 | } 289 | 290 | module List = { 291 | @module("@mui/material") @react.component 292 | external make: (~children: React.element) => React.element = "List" 293 | } 294 | 295 | module ListItem = { 296 | @module("@mui/material") @react.component 297 | external make: (~children: React.element, ~button: bool=?) => React.element = "ListItem" 298 | } 299 | 300 | module ListItemIcon = { 301 | @module("@mui/material") @react.component 302 | external make: (~children: React.element) => React.element = "ListItemIcon" 303 | } 304 | 305 | module ListItemText = { 306 | @module("@mui/material") @react.component 307 | external make: (~primary: string) => React.element = "ListItemText" 308 | } 309 | 310 | module Avatar = { 311 | @module("@mui/material") @react.component 312 | external make: (~src: string=?) => React.element = "Avatar" 313 | } 314 | 315 | module IconButton = { 316 | @module("@mui/material") @react.component 317 | external make: ( 318 | ~children: React.element, 319 | ~color: string=?, 320 | ~edge: string=?, 321 | ~sx: ReactDOM.Style.t=?, 322 | ~onClick: ReactEvent.Mouse.t => unit=?, 323 | ) => React.element = "IconButton" 324 | } 325 | 326 | module Menu = { 327 | @module("@mui/material") @react.component 328 | external make: ( 329 | ~children: React.element, 330 | ~_open: bool, 331 | ~id: string=?, 332 | ~anchorEl: 'a=?, 333 | ~anchorOrigin: 'b=?, 334 | ~transformOrigin: 'b=?, 335 | ~keepMounted: bool=?, 336 | ~onClose: unit => unit=?, 337 | ) => React.element = "Menu" 338 | } 339 | 340 | module MenuItem = { 341 | @module("@mui/material") @react.component 342 | external make: (~children: React.element) => React.element = "MenuItem" 343 | } 344 | 345 | module AppBar = { 346 | @module("@mui/material") @react.component 347 | external make: (~children: React.element, ~position: string=?) => React.element = "AppBar" 348 | } 349 | -------------------------------------------------------------------------------- /typescript/src/pages/NewOrder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import type { CreateRetweetOrderParams, CreateRetweetOrderErrorView, ExtendedRetweetOrderView } from 'types'; 4 | import { makeStyles } from '@mui/styles'; 5 | import { ApplicationBar } from 'components/applicationBarV2'; 6 | import { ApiClient } from 'apiClient'; 7 | import { Result } from 'result'; 8 | import Big from 'big.js'; 9 | import { assertNever } from "utils/assertNever"; 10 | 11 | import { 12 | Typography, Container, FormControl, TextField, FormHelperText, 13 | Grid, Box, Button, Toolbar, 14 | } from '@mui/material'; 15 | import { useHistory } from "react-router-dom"; 16 | import {formatNearAmount, formatNearAmount4} from 'utils/format'; 17 | 18 | const useStyles = makeStyles((theme) => ({ 19 | paper: { 20 | display: 'flex', 21 | flexDirection: 'column', 22 | alignItems: 'center', 23 | }, 24 | formContainer: { 25 | marginTop: '24px', 26 | }, 27 | })); 28 | 29 | interface NewOrderPageProps { 30 | loadMainState: () => void, 31 | } 32 | 33 | function NewOrderPage(props: NewOrderPageProps) { 34 | const { loadMainState } = props; 35 | 36 | const content = ( 37 | 38 | 39 | 40 | Create retweet order 41 | 42 | 43 | 44 | ); 45 | 46 | return ; 50 | } 51 | 52 | interface FormParams { 53 | tweetUrl: string; 54 | budget: string; 55 | numberOfTasks: string; 56 | taskCost: Big | null; 57 | } 58 | 59 | interface FormErrors { 60 | tweetUrl: string | null; 61 | budget: string | null; 62 | numberOfTasks: string | null; 63 | taskCost: string | null; 64 | } 65 | 66 | function newFormErrors(): FormErrors { 67 | return { 68 | tweetUrl: null, 69 | budget: null, 70 | numberOfTasks: null, 71 | taskCost: null, 72 | }; 73 | } 74 | 75 | function validate(params: FormParams): Result { 76 | const { tweetUrl, budget, numberOfTasks, taskCost } = params; 77 | 78 | const formErrors: FormErrors = newFormErrors(); 79 | 80 | // Tweet URL 81 | const tweetId = parseTweetId(tweetUrl); 82 | if (!tweetId) { 83 | formErrors.tweetUrl = "Tweet URL is invalid"; 84 | } 85 | 86 | // Budget 87 | const parsedBudget = parseFloat(budget); 88 | if (Number.isNaN(parsedBudget)) { 89 | formErrors.budget = "Invalid"; 90 | } else if (parsedBudget < 0.01) { 91 | formErrors.budget = "Budget must be greater than 0.01 NEAR"; 92 | } else if (parsedBudget > 1000) { 93 | formErrors.budget = "Uh. Is it not too much? Take it easy."; 94 | } 95 | 96 | // Number of tasks 97 | const parsedNumberOfTasks = parseInt(numberOfTasks); 98 | if (Number.isNaN(parsedNumberOfTasks)) { 99 | formErrors.numberOfTasks = "Invalid"; 100 | } else if (parsedNumberOfTasks < 1) { 101 | formErrors.numberOfTasks = "Must not be less than 1"; 102 | } else if (parsedNumberOfTasks > 10_000) { 103 | formErrors.numberOfTasks = "Sorry. 10K is maximum at the moment"; 104 | } 105 | 106 | // If taskCost is null, then the problem is either with Budger or numberOfTasks, which is handled by corresponding validation. 107 | if (taskCost && taskCost.toNumber() < 0.001) { 108 | formErrors.taskCost = "Can not be less than 0.001 Ⓝ. Please adjust Budget or Number of retweets."; 109 | } 110 | 111 | if (formErrors.tweetUrl || formErrors.budget || formErrors.numberOfTasks || formErrors.taskCost) { 112 | return Result.newErr(formErrors); 113 | } else { 114 | return Result.newOk({ 115 | tweetId: tweetId as string, // tweetId presence was verified before 116 | budget, 117 | numberOfTasks: parsedNumberOfTasks, 118 | }); 119 | } 120 | } 121 | 122 | interface OrderFormProps { 123 | loadMainState: () => void, 124 | } 125 | 126 | function OrderForm(props: OrderFormProps) { 127 | const { loadMainState } = props; 128 | 129 | const classes = useStyles(); 130 | const history = useHistory(); 131 | 132 | const defaultFormParams: FormParams = { 133 | tweetUrl: "", 134 | budget: "", 135 | numberOfTasks: "", 136 | taskCost: null, 137 | }; 138 | const defaultFormErrors: FormErrors = { 139 | tweetUrl: null, 140 | budget: null, 141 | numberOfTasks: null, 142 | taskCost: null, 143 | }; 144 | 145 | const [formParams, setFormParams] = React.useState(defaultFormParams); 146 | const [formErrors, setFormErrors] = React.useState(defaultFormErrors); 147 | 148 | const handleTweetUrlChange = (event: any) => { 149 | const tweetUrl = event.target.value; 150 | setFormParams({ ...formParams, tweetUrl }); 151 | }; 152 | const handleBudgetChange = (event: any) => { 153 | const budget = event.target.value; 154 | setFormParams({ ...formParams, budget }); 155 | }; 156 | const handleNumberOfTasksChange = (event: any) => { 157 | const numberOfTasks = event.target.value; 158 | setFormParams({ ...formParams, numberOfTasks }); 159 | }; 160 | 161 | // Calculate cost of a single retweet 162 | React.useEffect(() => { 163 | //const { numberOfTasks, budget } = formParams; 164 | try { 165 | let tasks: Big = Big(formParams.numberOfTasks); 166 | let totalAmount: Big = Big(formParams.budget); 167 | let taskCost: Big = Big(totalAmount.toNumber() / tasks.toNumber()); 168 | setFormParams({ ...formParams, taskCost }); 169 | } catch (err) { 170 | setFormParams({ ...formParams, taskCost: null }); 171 | } 172 | // eslint-disable-next-line 173 | }, [formParams.numberOfTasks, formParams.budget]); 174 | 175 | const clickHandler = (event: any) => { 176 | const result = validate(formParams); 177 | result.match({ 178 | ok: (params: CreateRetweetOrderParams) => { 179 | const apiClient = new ApiClient(); 180 | apiClient.createRetweetOrder(params).then((result) => { 181 | result.match({ 182 | ok: (order: ExtendedRetweetOrderView) => { 183 | loadMainState(); 184 | history.push('/orders/my'); 185 | }, 186 | err: (error: CreateRetweetOrderErrorView) => { 187 | setFormErrors(convertCreateOrderErrorToFormErrors(error)); 188 | }, 189 | }); 190 | }); 191 | }, 192 | err: (errors: FormErrors) => { 193 | setFormErrors(errors); 194 | } 195 | }); 196 | }; 197 | 198 | return ( 199 |
200 | 201 | 202 | 203 | 204 | 205 | { formErrors.tweetUrl === null 206 | ? URL of Tweet you want to promote 207 | : {formErrors.tweetUrl} 208 | } 209 | 210 | 211 | 212 | 213 | 214 | { formErrors.budget === null 215 | ? Maximum budget you want to spend 216 | : {formErrors.budget} 217 | } 218 | 219 | 220 | 221 | 222 | 223 | { formErrors.numberOfTasks === null 224 | ? Number of retweets you want to get for the given budget 225 | : {formErrors.numberOfTasks} 226 | } 227 | 228 | 229 | 230 | 231 | 232 | { formErrors.taskCost === null 233 | ? Cost of a single retweet. Calculated automatically based on Budget and Number of Retweets 234 | : {formErrors.taskCost} 235 | } 236 | 237 | 238 | 239 | 240 | 243 | 244 | 245 | 246 | 247 |
248 | ); 249 | } 250 | 251 | function parseTweetId(tweetUrl: string): string | null { 252 | const regex = /https:\/\/twitter\.com\/.*\/status\/(\d+)/i; 253 | const mat = tweetUrl.match(regex); 254 | if (!!mat && !!mat[1]) { 255 | return mat[1]; 256 | } else { 257 | return null; 258 | } 259 | } 260 | 261 | function convertCreateOrderErrorToFormErrors(error: CreateRetweetOrderErrorView): FormErrors { 262 | const formErrors: FormErrors = newFormErrors(); 263 | 264 | switch (error.tag) { 265 | case "NotEnoughAvailableBalance": { 266 | const availableBalance = Big(error.content.availableBalance); 267 | return { ...formErrors, budget: `You have only ${formatNearAmount(availableBalance)} available` }; 268 | } 269 | case "ActiveOrderAlreadyExists": { 270 | return { ...formErrors, tweetUrl: "An active order with this tweet already exist" }; 271 | } 272 | case "FailedToObtainTweet": { 273 | return { ...formErrors, tweetUrl: "It looks like the tweet does not exist or not available for public" }; 274 | } 275 | case "InvalidBudget": { 276 | return { ...formErrors, budget: "Invalid" }; 277 | } 278 | case "InvalidNumberOfTasks": { 279 | return { ...formErrors, numberOfTasks: "Invalid" }; 280 | } 281 | default: 282 | return assertNever(error); 283 | } 284 | } 285 | 286 | 287 | export { NewOrderPage }; 288 | -------------------------------------------------------------------------------- /rescript/src/pages/transactions/WithdrawPage.res: -------------------------------------------------------------------------------- 1 | type formParams = { 2 | recipient: string, 3 | amount: string, 4 | } 5 | 6 | type formErrors = { 7 | recipient: option, 8 | amount: option, 9 | } 10 | 11 | let emptyFormParams = (): formParams => { 12 | { 13 | recipient: "", 14 | amount: "", 15 | } 16 | } 17 | 18 | let emptyFormErrors = (): formErrors => { 19 | { 20 | recipient: None, 21 | amount: None, 22 | } 23 | } 24 | 25 | let validateRecipient = (recipient: string, ~networkId: Types.NearNetworkId.t): result< 26 | Types.NearAccountId.t, 27 | string, 28 | > => { 29 | open Types 30 | open Types.NearNetworkId 31 | 32 | let recipient = Js.String.trim(recipient) 33 | if recipient === "" { 34 | Error("Cannot be empty") 35 | } else { 36 | let regex = switch networkId { 37 | | Mainnet => %re("/^[a-zA-Z0-9-_]+\.near$/") 38 | | Testnet => %re("/^[a-zA-Z0-9-_]+\.testnet$/") 39 | } 40 | switch Js.Re.exec_(regex, recipient) { 41 | | Some(_) => recipient->NearAccountId.fromString->Ok 42 | | None => Error("Is invalid") 43 | } 44 | } 45 | } 46 | 47 | let getError = (result: result<'a, 'e>): option<'e> => { 48 | switch result { 49 | | Ok(_) => None 50 | | Error(err) => Some(err) 51 | } 52 | } 53 | 54 | let validateAmount = (amount: string, ~availableBalance: Big.t): result => { 55 | let amount = Js.String.trim(amount) 56 | if amount === "" { 57 | Error("Cannot be empty") 58 | } else { 59 | switch Big.parse(amount) { 60 | | None => Error("Is invalid") 61 | | Some(amount) => 62 | if Big.lt(amount, Big.fromString("0.1")) { 63 | Error("The minimal amount for withdrawal is 0.1 NEAR") 64 | } else if Big.gt(amount, Big.fromString("20")) { 65 | Error("The maximum amount for withdrawal is 20 NEAR") 66 | } else if Big.gt(amount, availableBalance) { 67 | Error(`You have only ${Format.formatNearAmount4(availableBalance)} available`) 68 | } else { 69 | Ok(amount) 70 | } 71 | } 72 | } 73 | } 74 | 75 | let convertWithdrawErrorViewToFormErrors = (error: Types.WithdrawErrorView.t): formErrors => { 76 | open Types.WithdrawErrorView 77 | open Types 78 | let formatNear = Format.formatNearAmount4 79 | 80 | switch error { 81 | | InvalidRecipient => {...emptyFormErrors(), recipient: Some("Is invalid")} 82 | | RecipientAccountDoesNotExist({recipientAccountId}) => { 83 | ...emptyFormErrors(), 84 | recipient: Some(`Account ${NearAccountId.toString(recipientAccountId)} does not exist`), 85 | } 86 | | RequestedAmountTooSmall({minAmount, requestedAmount: _}) => { 87 | ...emptyFormErrors(), 88 | amount: Some(`The minimal amount for withdrawal is ${formatNear(minAmount)}`), 89 | } 90 | | RequestedAmountTooHigh({maxAmount, requestedAmount: _}) => { 91 | ...emptyFormErrors(), 92 | amount: Some(`The maximum amount for withdrawal is ${formatNear(maxAmount)}`), 93 | } 94 | | InsufficientFunds({availableBalance, requestedAmount: _}) => { 95 | ...emptyFormErrors(), 96 | amount: Some(`Available balance is ${formatNear(availableBalance)}, what is not sufficient`), 97 | } 98 | } 99 | } 100 | 101 | let formatWithdrawError = (error: Types.WithdrawErrorView.t): string => { 102 | open Types.WithdrawErrorView 103 | open Types 104 | let formatNear = Format.formatNearAmount4 105 | 106 | switch error { 107 | | InvalidRecipient => "Recipient is invalid" 108 | | RecipientAccountDoesNotExist({recipientAccountId}) => 109 | `Recipient account ${NearAccountId.toString(recipientAccountId)} does not exist` 110 | | RequestedAmountTooSmall({minAmount, requestedAmount}) => 111 | `The minimal amount for withdrawal is ${formatNear( 112 | minAmount, 113 | )}, but was requested only ${formatNear(requestedAmount)}` 114 | | RequestedAmountTooHigh({maxAmount, requestedAmount}) => 115 | `The maximum amount for withdrawal is ${formatNear(maxAmount)}, but was requested ${formatNear( 116 | requestedAmount, 117 | )}` 118 | | InsufficientFunds({availableBalance, requestedAmount}) => 119 | `Available balance is ${formatNear( 120 | availableBalance, 121 | )}, what is not sufficient to withdraw ${formatNear(requestedAmount)}` 122 | } 123 | } 124 | 125 | let linkToNearAccount = ( 126 | networkId: Types.NearNetworkId.t, 127 | account: Types.NearAccountId.t, 128 | ): React.element => { 129 | open Types 130 | 131 | let url = Near.nearAccountUrl(networkId, account) 132 | let account = account->NearAccountId.toString->React.string 133 | {account} 134 | } 135 | 136 | let linkToNearTransaction = ( 137 | networkId: Types.NearNetworkId.t, 138 | hash: Types.NearTransactionHash.t, 139 | ): React.element => { 140 | open Types 141 | 142 | let url = Near.nearTransactionUrl(networkId, hash) 143 | let hash = hash->NearTransactionHash.toString->React.string 144 | {hash} 145 | } 146 | 147 | module Success = { 148 | open Mui 149 | 150 | @react.component 151 | let make = (~response: Types.WithdrawResponseView.t, ~nearNetworkId: Types.NearNetworkId.t) => { 152 | let {amount, nearTransactionHash, recipientNearAccountId} = response 153 | 154 | 155 | 156 | {React.string(`Success! 🎉🎉🎉 `)} 157 |

158 | {React.string(`Amount of ${Format.formatNearAmount4(amount)} is withdrawn to `)} 159 | {linkToNearAccount(nearNetworkId, recipientNearAccountId)} 160 |

161 |

162 | {React.string("Transaction ")} {linkToNearTransaction(nearNetworkId, nearTransactionHash)} 163 |

164 |
165 |
166 | } 167 | } 168 | 169 | let validate = ( 170 | params: formParams, 171 | ~networkId: Types.NearNetworkId.t, 172 | ~availableBalance: Big.t, 173 | ): result => { 174 | let recipientResult = validateRecipient(params.recipient, ~networkId) 175 | let amountResult = validateAmount(params.amount, ~availableBalance) 176 | 177 | switch (recipientResult, amountResult) { 178 | | (Ok(recipientNearAccountId), Ok(amount)) => 179 | Ok({recipientNearAccountId: recipientNearAccountId, amount: amount}) 180 | | _ => 181 | Error({ 182 | recipient: getError(recipientResult), 183 | amount: getError(amountResult), 184 | }) 185 | } 186 | } 187 | 188 | module WithdrawForm = { 189 | open AsyncData 190 | open Mui 191 | 192 | let helperTextOrError = (helperText: string, error: option) => { 193 | switch error { 194 | | Some(errMsg) => {React.string(errMsg)} 195 | | None => {React.string(helperText)} 196 | } 197 | } 198 | 199 | @react.component 200 | let make = (~nearNetworkId: Types.NearNetworkId.t, ~availableBalance: Big.t) => { 201 | let {reloadUser} = React.useContext(UserContext.context) 202 | 203 | let (formParams, setFormParams) = React.useState(emptyFormParams) 204 | let (formErrors, setFormErrors) = React.useState(emptyFormErrors) 205 | let (withdrawResponse, setWithdrawResponse) = React.useState(_ => NotAsked) 206 | 207 | let handleRecipientChange = (event: Mui.changeEvent) => { 208 | let recipient = event.target.value 209 | setFormParams(oldParams => {...oldParams, recipient: recipient}) 210 | } 211 | let handleAmountChange = (event: Mui.changeEvent) => { 212 | let amount = event.target.value 213 | setFormParams(oldParams => {...oldParams, amount: amount}) 214 | } 215 | 216 | let handleSubmit = _ => { 217 | let validationResult = validate(formParams, ~availableBalance, ~networkId=nearNetworkId) 218 | switch validationResult { 219 | | Ok(validWithdrawParams) => { 220 | setFormErrors(_ => emptyFormErrors()) 221 | setWithdrawResponse(_ => Loading) 222 | Api.withdraw(validWithdrawParams) 223 | ->Promise.then(withdrawResult => { 224 | switch withdrawResult { 225 | | Ok(resp) => { 226 | setWithdrawResponse(_ => Done(Ok(resp))) 227 | reloadUser() 228 | } 229 | | Error(error) => { 230 | setFormErrors(_ => convertWithdrawErrorViewToFormErrors(error)) 231 | let errMsg = formatWithdrawError(error) 232 | setWithdrawResponse(_ => Done(Error(errMsg))) 233 | } 234 | } 235 | Promise.resolve() 236 | }) 237 | ->ignore 238 | } 239 | | Error(errors) => setFormErrors(_ => errors) 240 | } 241 | } 242 | 243 | let renderForm = (~isLoading: bool, ~commonError: option) => { 244 | let errorGridItem = switch commonError { 245 | | Some(errMsg) => 246 | 247 | 248 | {React.string("Withdrawal failed")} {React.string(errMsg)} 249 | 250 | 251 | | None => React.null 252 | } 253 | 254 | 255 | // Recipient 256 | 257 | 258 | 268 | 269 | {helperTextOrError("NEAR account address (e.g. vasya.near)", formErrors.recipient)} 270 | 271 | // Amount 272 | 273 | 274 | 284 | 285 | {helperTextOrError( 286 | `You have ${Format.formatNearAmount4(availableBalance)} available for withdrawal`, 287 | formErrors.amount, 288 | )} 289 | 290 | { 291 | // Common error 292 | errorGridItem 293 | } 294 | // Button 295 | 296 | 297 | 303 | {React.string("Withdraw")} 304 | 305 | 306 | 307 | 308 | 309 | } 310 | 311 | switch withdrawResponse { 312 | | NotAsked => renderForm(~isLoading=false, ~commonError=None) 313 | | Loading => renderForm(~isLoading=true, ~commonError=None) 314 | | Done(Error(errMsg)) => renderForm(~isLoading=false, ~commonError=Some(errMsg)) 315 | | Done(Ok(response)) => 316 | } 317 | } 318 | } 319 | 320 | @react.component 321 | let make = (~nearNetworkId: Types.NearNetworkId.t, ~availableBalance: Big.t) => { 322 | open Mui 323 | 324 | 325 | 326 | 327 | {React.string("Deposit")} 328 | {React.string("Withdraw")} 329 | 330 | 331 | 332 | 333 | {React.string("Withdraw NEAR")} 334 | 335 | 336 | 337 | 338 | } 339 | --------------------------------------------------------------------------------