From 1a69749a184c7e90b8b6129397f2f859e25bd727 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 25 May 2023 14:54:54 +0200 Subject: [PATCH 001/135] import project - basic layout - poap api bindings - network status - web3 status (xumm, gem wallet support) - mint page/dialog --- .env.example | 2 + .gitignore | 24 ++++ README.md | 39 +++++- package.json | 61 ++++++++ public/favicon.ico | Bin 0 -> 3870 bytes public/index.html | 28 ++++ src/App.test.tsx | 7 + src/App.tsx | 36 +++++ src/apis/index.ts | 17 +++ src/apis/poap.ts | 150 ++++++++++++++++++++ src/components/Header.tsx | 86 ++++++++++++ src/components/Loadable.tsx | 13 ++ src/components/Loader.tsx | 16 +++ src/components/MintDialog.tsx | 230 +++++++++++++++++++++++++++++++ src/components/NetworkStatus.tsx | 39 ++++++ src/components/Web3Status.tsx | 165 ++++++++++++++++++++++ src/config.ts | 18 +++ src/connectors/chain.ts | 7 + src/connectors/connector.ts | 22 +++ src/connectors/context.tsx | 67 +++++++++ src/connectors/empty.ts | 13 ++ src/connectors/gem.ts | 85 ++++++++++++ src/connectors/index.ts | 33 +++++ src/connectors/provider.ts | 12 ++ src/connectors/state.ts | 122 ++++++++++++++++ src/connectors/xumm.ts | 120 ++++++++++++++++ src/index.tsx | 18 +++ src/layouts/MainLayout.tsx | 28 ++++ src/pages/ErrorPage.tsx | 30 ++++ src/pages/HomePage.tsx | 52 +++++++ src/pages/NotFoundPage.tsx | 20 +++ src/react-app-env.d.ts | 1 + src/reportWebVitals.ts | 15 ++ src/routes/DefaultRoutes.tsx | 11 ++ src/routes/MainRoutes.tsx | 37 +++++ src/routes/index.ts | 8 ++ src/setupTests.ts | 5 + src/states/atoms.ts | 6 + src/utils/strings.ts | 16 +++ tsconfig.json | 21 +++ 40 files changed, 1678 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package.json create mode 100644 public/favicon.ico create mode 100644 public/index.html create mode 100644 src/App.test.tsx create mode 100644 src/App.tsx create mode 100644 src/apis/index.ts create mode 100644 src/apis/poap.ts create mode 100644 src/components/Header.tsx create mode 100644 src/components/Loadable.tsx create mode 100644 src/components/Loader.tsx create mode 100644 src/components/MintDialog.tsx create mode 100644 src/components/NetworkStatus.tsx create mode 100644 src/components/Web3Status.tsx create mode 100644 src/config.ts create mode 100644 src/connectors/chain.ts create mode 100644 src/connectors/connector.ts create mode 100644 src/connectors/context.tsx create mode 100644 src/connectors/empty.ts create mode 100644 src/connectors/gem.ts create mode 100644 src/connectors/index.ts create mode 100644 src/connectors/provider.ts create mode 100644 src/connectors/state.ts create mode 100644 src/connectors/xumm.ts create mode 100644 src/index.tsx create mode 100644 src/layouts/MainLayout.tsx create mode 100644 src/pages/ErrorPage.tsx create mode 100644 src/pages/HomePage.tsx create mode 100644 src/pages/NotFoundPage.tsx create mode 100644 src/react-app-env.d.ts create mode 100644 src/reportWebVitals.ts create mode 100644 src/routes/DefaultRoutes.tsx create mode 100644 src/routes/MainRoutes.tsx create mode 100644 src/routes/index.ts create mode 100644 src/setupTests.ts create mode 100644 src/states/atoms.ts create mode 100644 src/utils/strings.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bb38196 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +REACT_APP_URL_POAP_API="http://localhost:4000/" +REACT_APP_KEY_XUMM_API="57484a14-db63-4c72-a0b7-4e5024a18bd7" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8692cf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/README.md b/README.md index a71a58a..94650cd 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,37 @@ -# POAP-APP -The web app for Proof of Attendance Protocol +# POAP APP + +The web app for Proof of Attendance Protocol + +## Requirements +- Running POAP API server (backend), for details see [here](https://github.com/rikublock/POAP-API) + +## Available Scripts + +In the project directory, you can run: + +### `yarn` + +Install dependencies. + +### `yarn start` + +Runs the app in the development mode.\ +Open [http://localhost:3000](http://localhost:3000) to view it in the browser. + +The page will reload if you make edits.\ +You will also see any lint errors in the console. + +### `yarn test` + +Launches the test runner in the interactive watch mode.\ +See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. + +### `yarn build` + +Builds the app for production to the `build` folder.\ +It correctly bundles React in production mode and optimizes the build for the best performance. + +The build is minified and the filenames include the hashes.\ +Your app is ready to be deployed! + +See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. diff --git a/package.json b/package.json new file mode 100644 index 0000000..bf9e578 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "poap-app", + "version": "0.1.0", + "description": "Dashboard for the Proof of Attendance Protocol", + "author": "Riku Block", + "private": true, + "dependencies": { + "@emotion/react": "^11.11.0", + "@emotion/styled": "^11.11.0", + "@gemwallet/api": "^2.2.1", + "@mui/icons-material": "^5.11.16", + "@mui/material": "^5.13.2", + "@testing-library/jest-dom": "^5.14.1", + "@testing-library/react": "^13.0.0", + "@testing-library/user-event": "^13.2.1", + "@types/jest": "^27.0.1", + "@types/node": "^16.7.13", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "axios": "^1.4.0", + "crypto-browserify": "^3.12.0", + "https-browserify": "^1.0.0", + "jotai": "^2.1.0", + "notistack": "^3.0.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.11.2", + "react-scripts": "5.0.1", + "stream-browserify": "^3.0.0", + "typescript": "^4.4.2", + "verify-xrpl-signature": "^1.0.0", + "web-vitals": "^2.1.0", + "xrpl": "^2.7.0", + "xumm": "^1.4.0", + "zustand": "^4.3.8" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a GIT binary patch literal 3870 zcma);c{J4h9>;%nil|2-o+rCuEF-(I%-F}ijC~o(k~HKAkr0)!FCj~d>`RtpD?8b; zXOC1OD!V*IsqUwzbMF1)-gEDD=A573Z-&G7^LoAC9|WO7Xc0Cx1g^Zu0u_SjAPB3vGa^W|sj)80f#V0@M_CAZTIO(t--xg= z!sii`1giyH7EKL_+Wi0ab<)&E_0KD!3Rp2^HNB*K2@PHCs4PWSA32*-^7d{9nH2_E zmC{C*N*)(vEF1_aMamw2A{ZH5aIDqiabnFdJ|y0%aS|64E$`s2ccV~3lR!u<){eS` z#^Mx6o(iP1Ix%4dv`t@!&Za-K@mTm#vadc{0aWDV*_%EiGK7qMC_(`exc>-$Gb9~W!w_^{*pYRm~G zBN{nA;cm^w$VWg1O^^<6vY`1XCD|s_zv*g*5&V#wv&s#h$xlUilPe4U@I&UXZbL z0)%9Uj&@yd03n;!7do+bfixH^FeZ-Ema}s;DQX2gY+7g0s(9;`8GyvPY1*vxiF&|w z>!vA~GA<~JUqH}d;DfBSi^IT*#lrzXl$fNpq0_T1tA+`A$1?(gLb?e#0>UELvljtQ zK+*74m0jn&)5yk8mLBv;=@}c{t0ztT<v;Avck$S6D`Z)^c0(jiwKhQsn|LDRY&w(Fmi91I7H6S;b0XM{e zXp0~(T@k_r-!jkLwd1_Vre^v$G4|kh4}=Gi?$AaJ)3I+^m|Zyj#*?Kp@w(lQdJZf4 z#|IJW5z+S^e9@(6hW6N~{pj8|NO*>1)E=%?nNUAkmv~OY&ZV;m-%?pQ_11)hAr0oAwILrlsGawpxx4D43J&K=n+p3WLnlDsQ$b(9+4 z?mO^hmV^F8MV{4Lx>(Q=aHhQ1){0d*(e&s%G=i5rq3;t{JC zmgbn5Nkl)t@fPH$v;af26lyhH!k+#}_&aBK4baYPbZy$5aFx4}ka&qxl z$=Rh$W;U)>-=S-0=?7FH9dUAd2(q#4TCAHky!$^~;Dz^j|8_wuKc*YzfdAht@Q&ror?91Dm!N03=4=O!a)I*0q~p0g$Fm$pmr$ zb;wD;STDIi$@M%y1>p&_>%?UP($15gou_ue1u0!4(%81;qcIW8NyxFEvXpiJ|H4wz z*mFT(qVx1FKufG11hByuX%lPk4t#WZ{>8ka2efjY`~;AL6vWyQKpJun2nRiZYDij$ zP>4jQXPaP$UC$yIVgGa)jDV;F0l^n(V=HMRB5)20V7&r$jmk{UUIe zVjKroK}JAbD>B`2cwNQ&GDLx8{pg`7hbA~grk|W6LgiZ`8y`{Iq0i>t!3p2}MS6S+ zO_ruKyAElt)rdS>CtF7j{&6rP-#c=7evGMt7B6`7HG|-(WL`bDUAjyn+k$mx$CH;q2Dz4x;cPP$hW=`pFfLO)!jaCL@V2+F)So3}vg|%O*^T1j>C2lx zsURO-zIJC$^$g2byVbRIo^w>UxK}74^TqUiRR#7s_X$e)$6iYG1(PcW7un-va-S&u zHk9-6Zn&>T==A)lM^D~bk{&rFzCi35>UR!ZjQkdSiNX*-;l4z9j*7|q`TBl~Au`5& z+c)*8?#-tgUR$Zd%Q3bs96w6k7q@#tUn`5rj+r@_sAVVLqco|6O{ILX&U-&-cbVa3 zY?ngHR@%l{;`ri%H*0EhBWrGjv!LE4db?HEWb5mu*t@{kv|XwK8?npOshmzf=vZA@ zVSN9sL~!sn?r(AK)Q7Jk2(|M67Uy3I{eRy z_l&Y@A>;vjkWN5I2xvFFTLX0i+`{qz7C_@bo`ZUzDugfq4+>a3?1v%)O+YTd6@Ul7 zAfLfm=nhZ`)P~&v90$&UcF+yXm9sq!qCx3^9gzIcO|Y(js^Fj)Rvq>nQAHI92ap=P z10A4@prk+AGWCb`2)dQYFuR$|H6iDE8p}9a?#nV2}LBCoCf(Xi2@szia7#gY>b|l!-U`c}@ zLdhvQjc!BdLJvYvzzzngnw51yRYCqh4}$oRCy-z|v3Hc*d|?^Wj=l~18*E~*cR_kU z{XsxM1i{V*4GujHQ3DBpl2w4FgFR48Nma@HPgnyKoIEY-MqmMeY=I<%oG~l!f<+FN z1ZY^;10j4M4#HYXP zw5eJpA_y(>uLQ~OucgxDLuf}fVs272FaMxhn4xnDGIyLXnw>Xsd^J8XhcWIwIoQ9} z%FoSJTAGW(SRGwJwb=@pY7r$uQRK3Zd~XbxU)ts!4XsJrCycrWSI?e!IqwqIR8+Jh zlRjZ`UO1I!BtJR_2~7AbkbSm%XQqxEPkz6BTGWx8e}nQ=w7bZ|eVP4?*Tb!$(R)iC z9)&%bS*u(lXqzitAN)Oo=&Ytn>%Hzjc<5liuPi>zC_nw;Z0AE3Y$Jao_Q90R-gl~5 z_xAb2J%eArrC1CN4G$}-zVvCqF1;H;abAu6G*+PDHSYFx@Tdbfox*uEd3}BUyYY-l zTfEsOqsi#f9^FoLO;ChK<554qkri&Av~SIM*{fEYRE?vH7pTAOmu2pz3X?Wn*!ROX ztd54huAk&mFBemMooL33RV-*1f0Q3_(7hl$<#*|WF9P!;r;4_+X~k~uKEqdzZ$5Al zV63XN@)j$FN#cCD;ek1R#l zv%pGrhB~KWgoCj%GT?%{@@o(AJGt*PG#l3i>lhmb_twKH^EYvacVY-6bsCl5*^~L0 zonm@lk2UvvTKr2RS%}T>^~EYqdL1q4nD%0n&Xqr^cK^`J5W;lRRB^R-O8b&HENO||mo0xaD+S=I8RTlIfVgqN@SXDr2&-)we--K7w= zJVU8?Z+7k9dy;s;^gDkQa`0nz6N{T?(A&Iz)2!DEecLyRa&FI!id#5Z7B*O2=PsR0 zEvc|8{NS^)!d)MDX(97Xw}m&kEO@5jqRaDZ!+%`wYOI<23q|&js`&o4xvjP7D_xv@ z5hEwpsp{HezI9!~6O{~)lLR@oF7?J7i>1|5a~UuoN=q&6N}EJPV_GD`&M*v8Y`^2j zKII*d_@Fi$+i*YEW+Hbzn{iQk~yP z>7N{S4)r*!NwQ`(qcN#8SRQsNK6>{)X12nbF`*7#ecO7I)Q$uZsV+xS4E7aUn+U(K baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ literal 0 HcmV?d00001 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0d5bf07 --- /dev/null +++ b/public/index.html @@ -0,0 +1,28 @@ + + + + + + + + + POAP Dashboard + + + +
+ + + diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..cd3484a --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,7 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import App from "./App"; + +test("nothing", () => { + render(); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..a72832d --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,36 @@ +import { BrowserRouter } from "react-router-dom"; +import { Provider as JotaiProvider } from "jotai"; +import { SnackbarProvider } from "notistack"; +import Routes from "routes"; + +import { createTheme, ThemeProvider } from "@mui/material/styles"; +import { CssBaseline } from "@mui/material"; +import Slide from "@mui/material/Slide"; + +import { Web3Provider } from "connectors/context"; + +function App() { + const theme = createTheme(); + + return ( + + + + + + + + + + + + + ); +} + +export default App; diff --git a/src/apis/index.ts b/src/apis/index.ts new file mode 100644 index 0000000..33fd412 --- /dev/null +++ b/src/apis/index.ts @@ -0,0 +1,17 @@ +import { + mint, + claim, + startVerification, + verifyOwnership, + attendees, +} from "apis/poap"; + +const API = { + mint, + claim, + startVerification, + verifyOwnership, + attendees, +}; + +export default API; diff --git a/src/apis/poap.ts b/src/apis/poap.ts new file mode 100644 index 0000000..b2bc401 --- /dev/null +++ b/src/apis/poap.ts @@ -0,0 +1,150 @@ +import axios from "axios"; +import config from "config"; + +export type mintParams = { + walletAddress: string; + tokenCount: number; + url: string; + title: string; + desc: string; + loc: string; +} + +export type mintResult = { + eventId: number; + account: string; + owner: string; + URI: string; + title: string; + claimable: number; +} + +export const mint = async (params: mintParams): Promise => { + const response = await axios.get( + new URL("/api/mint", config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + params: params, + }, + ); + + if (response.status === 200) { + return response.data.result as mintResult; + } + + throw new Error(response.status.toString()); +}; + +export type claimParams = { + walletAddress: string; + type: number; + minter: string; + eventId: string | number; +} + +export type claimResult = { + status: string; + result?: any; + offer?: any; + // TODO +} + +export const claim = async (params: claimParams): Promise => { + const response = await axios.get( + new URL("/api/claim", config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + params: params, + }, + ); + + if (response.status === 200) { + return response.data as claimResult; + } + + throw new Error(response.status.toString()); +}; + +export type startVerificationParams = { + walletAddress: string; +} + +export type startVerificationResult = string; + +export const startVerification = async (params: startVerificationParams): Promise => { + const response = await axios.get( + new URL("/api/startVerification", config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + params: params, + }, + ); + + if (response.status === 200) { + return response.data.result as startVerificationResult; + } + + throw new Error(response.status.toString()); +}; + +export type verifyOwnershipParams = { + walletAddress: string; + signature: string; + minter: string; + eventId: string | number; +} + +export type verifyOwnershipResult = { + // TODO +} + +export const verifyOwnership = async (params: verifyOwnershipParams): Promise => { + const response = await axios.get( + new URL("/api/verifyOwnership", config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + params: params, + }, + ); + + if (response.status === 200) { + return response.data as verifyOwnershipResult; + } + + throw new Error(response.status.toString()); +}; + +export type attendeesParams = { + minter: string; + eventId: string | number; +} + +type User = { + user: string; +} + +export type attendeesResult = { + [index: number]: User; + // TODO +} + +export const attendees = async (params: attendeesParams): Promise => { + const response = await axios.get( + new URL("/api/attendees", config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + params: params, + }, + ); + + if (response.status === 200) { + return response.data.result as attendeesResult; + } + + throw new Error(response.status.toString()); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..2f86196 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; + +import { styled } from "@mui/system"; +import AppBar from "@mui/material/AppBar"; +import Box from "@mui/material/Box"; +import Container from "@mui/material/Container"; +import { List as MuiList } from "@mui/material"; +import ListItem from "@mui/material/ListItem"; +import ListItemButton from "@mui/material/ListItemButton"; +import ListItemText from "@mui/material/ListItemText"; +import Toolbar from "@mui/material/Toolbar"; +import Stack from "@mui/material/Stack"; + +import Web3Status from "components/Web3Status"; +import NetworkStatus from "components/NetworkStatus"; + +const StyledList = styled(MuiList)<{ component?: React.ElementType }>( + ({ theme }) => ({ + display: "flex", + flexDirection: "row", + padding: 0, + "& .MuiListItem-root": { + padding: 0, + }, + "& .MuiListItemText-root": { + margin: 0, + }, + "& .MuiListItemButton-root": { + padding: "6px 12px", + "&:hover": { + color: "#09c1d1", + }, + "&.active": { + color: theme.palette.primary.dark, + }, + }, + }) +) as typeof MuiList; + +function Header() { + const entries: Array<[string, string, boolean]> = [ + ["Home", "/", false], + ["Claim", "/claim", true], + ["Verify", "/verify", true], + ["Overview", "/overview", true], + ]; + + return ( + + + + + + {entries.map(([name, to, disabled], i) => ( + + + + + + ))} + + + + + + + + + + ); +} + +export default Header; diff --git a/src/components/Loadable.tsx b/src/components/Loadable.tsx new file mode 100644 index 0000000..6b5b05b --- /dev/null +++ b/src/components/Loadable.tsx @@ -0,0 +1,13 @@ +import { Suspense } from "react"; + +import Loader from "components/Loader"; + +const Loadable = (Component: any) => (props: any) => { + return ( + }> + + + ); +}; + +export default Loadable; diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 0000000..40490c4 --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,16 @@ +import Box from "@mui/material/Box"; +import CircularProgress from "@mui/material/CircularProgress"; +import Typography from "@mui/material/Typography"; + +function Loader() { + return ( + + + + Loading + + + ); +} + +export default Loader; diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx new file mode 100644 index 0000000..f73e5de --- /dev/null +++ b/src/components/MintDialog.tsx @@ -0,0 +1,230 @@ +import React from "react"; +import type { ReactNode, Dispatch } from "react"; +import { useSnackbar } from "notistack"; + +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import TextField from "@mui/material/TextField"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import CloseIcon from "@mui/icons-material/Close"; +import Stack from "@mui/material/Stack"; +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; + +type MintDialogContent = { + title: string; + description: string; + location: string; + url: string; + tokenCount: string; +}; + +const DEFAULT_CONTENT = { + title: "", + description: "", + location: "", + url: "", + tokenCount: "", +}; + +type MintDialogProps = { + children?: ReactNode; + open: boolean; + setOpen: Dispatch; +}; + +function MintDialog(props: MintDialogProps) { + const { open, setOpen } = props; + const { account } = useWeb3(); + const [loading, setLoading] = React.useState(false); + const [content, setContent] = + React.useState(DEFAULT_CONTENT); + const { enqueueSnackbar } = useSnackbar(); + + const handleClose = (event: {}, reason?: string) => { + if (reason === "backdropClick") { + return; + } + setOpen(false); + }; + + const handleCancel = (event: React.MouseEvent) => { + setContent(DEFAULT_CONTENT); + setOpen(false); + }; + + const handleConfirm = async (event: React.MouseEvent) => { + setLoading(true); + try { + if (account) { + const result = await API.mint({ + walletAddress: account, + title: content.title, + desc: content.description, + loc: content.location, + url: content.url, + tokenCount: parseInt(content.tokenCount), + }); + console.debug("mintResult", result); + enqueueSnackbar(`Mint successful: Event ID #${result.eventId}`, { + variant: "success", + }); + } + } catch (error) { + console.debug(error); + enqueueSnackbar(`Mint failed: ${(error as Error).message}`, { + variant: "error", + }); + } finally { + setLoading(false); + } + setContent(DEFAULT_CONTENT); + setOpen(false); + }; + + const validateChange = (text: string, name: string): boolean => { + if (text === "") { + return true; + } + + if (name === "tokenCount") { + return parseInt(text) > 0; + } else { + return true; + } + }; + + const handleChange = ( + event: React.ChangeEvent + ) => { + const value = event.target.value; + const name = event.target.name; + if (validateChange(value, name)) { + setContent({ ...content, [name]: value }); + } + }; + + // TODO better validation, for each input separately + const validateContent = React.useCallback(() => { + if (content.title.length === 0) { + return false; + } + if (content.description.length === 0) { + return false; + } + if (content.location.length === 0) { + return false; + } + if (content.url.length === 0) { + // TODO regexp match + return false; + } + if (!(parseInt(content.tokenCount) > 0)) { + return false; + } + return true; + }, [content]); + + return ( + + + Create new Event + + theme.palette.grey[500], + }} + size="small" + > + + + + + + + + + + + + + + + + + ); +} + +export default MintDialog; diff --git a/src/components/NetworkStatus.tsx b/src/components/NetworkStatus.tsx new file mode 100644 index 0000000..d3eec8b --- /dev/null +++ b/src/components/NetworkStatus.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import Button from "@mui/material/Button"; +import { useWeb3 } from "connectors/context"; +import { ChainIdentifier } from "connectors/chain"; + +function NetworkStatus() { + const { chainId } = useWeb3(); + + const networkName = React.useMemo(() => { + switch (chainId) { + case ChainIdentifier.MAINNET: + return "Mainnet"; + case ChainIdentifier.TESTNET: + return "Testnet"; + case ChainIdentifier.DEVNET: + return "Devnet"; + case ChainIdentifier.AMM_DEVNET: + return "AMM-Devnet"; + default: + return "Unknown"; + } + }, [chainId]); + + return ( + + + + ); +} + +export default NetworkStatus; diff --git a/src/components/Web3Status.tsx b/src/components/Web3Status.tsx new file mode 100644 index 0000000..8d31ad2 --- /dev/null +++ b/src/components/Web3Status.tsx @@ -0,0 +1,165 @@ +import React from "react"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; + +import { styled } from "@mui/material/styles"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Menu, { MenuProps } from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; + +import { gem } from "connectors/gem"; +import { selectedWalletAtom } from "states/atoms"; +import { shortenAddress } from "utils/strings"; +import { useWeb3 } from "connectors/context"; +import { xumm } from "connectors/xumm"; +import { ConnectorType, getConnector } from "connectors"; +import type { Connector } from "connectors/connector"; + +const StyledMenu = styled((props: MenuProps) => ( +
+))(({ theme }) => ({ + "& .MuiPaper-root": { + marginTop: theme.spacing(0.5), + boxShadow: + "rgb(255, 255, 255) 0px 0px 0px 0px, rgba(0, 0, 0, 0.05) 0px 0px 0px 1px, rgba(0, 0, 0, 0.1) 0px 10px 15px -3px, rgba(0, 0, 0, 0.05) 0px 4px 6px -2px", + "& .MuiMenu-list": { + padding: "4px 0", + }, + }, +})); + +const DEFAULT_STATUS = "CONNECT WALLET"; + +function Web3Status() { + const { connector, account, isActive } = useWeb3(); + const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); + const [status, setStatus] = React.useState(DEFAULT_STATUS); + const [menuAnchor, setMenuAnchor] = React.useState(null); + const { enqueueSnackbar } = useSnackbar(); + + // eagerly connect + let selectedConnector: Connector | undefined; + if (selectedWallet) { + try { + selectedConnector = getConnector(selectedWallet as ConnectorType); + } catch { + setSelectedWallet(ConnectorType.EMPTY); + } + } + + React.useEffect(() => { + if (selectedConnector && !selectedConnector.state.isActive()) { + // Note: Don't eagerly connect to the GemWallet as it currently prompts + // the user for a password every time the page is refreshed. + if (selectedConnector !== gem) { + selectedConnector.activate(); + } + } + }, [selectedConnector]); + + React.useEffect(() => { + if (account) { + setStatus(shortenAddress(account)); + } else { + setStatus(DEFAULT_STATUS); + } + }, [account, isActive]); + + const handleMenuClose = React.useCallback(() => { + setMenuAnchor(null); + }, []); + + const handleClick = React.useCallback( + async (event: React.MouseEvent) => { + setMenuAnchor(event.currentTarget); + }, + [] + ); + + const handleAction = React.useCallback( + async (action: string) => { + setMenuAnchor(null); + if (action === "disconnect") { + try { + if (connector?.deactivate) { + await connector.deactivate(); + } else { + await connector?.reset(); + } + setSelectedWallet(ConnectorType.EMPTY); + } catch (error) { + enqueueSnackbar( + `Failed to disconnect wallet: ${(error as Error).message}`, + { variant: "error" } + ); + } + } else if (action === "xumm") { + try { + await xumm.activate(); + setSelectedWallet(ConnectorType.XUMM); + } catch (error) { + enqueueSnackbar( + `Failed to connect wallet: ${(error as Error).message}`, + { variant: "error" } + ); + } + } else if (action === "gem") { + try { + await gem.activate(); + setSelectedWallet(ConnectorType.GEM); + } catch (error) { + enqueueSnackbar( + `Failed to connect wallet: ${(error as Error).message}`, + { variant: "error" } + ); + } + } + }, + [connector, enqueueSnackbar, setSelectedWallet] + ); + + return ( + + + + handleAction("xumm")}>Xumm Wallet + handleAction("gem")}>Gem Wallet + + + handleAction("disconnect")}> + Disconnect + + + + ); +} + +export default Web3Status; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..a866ca9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,18 @@ +const DEFAULT = { + apiURL: process.env.REACT_APP_URL_POAP_API as string, + timeout: 600000, + + connector :{ + xumm: { + apiKey: process.env.REACT_APP_KEY_XUMM_API as string, + options: { + implicit: true, + storage: window.sessionStorage, + }, + } + }, +}; + +const config = DEFAULT; + +export default config; diff --git a/src/connectors/chain.ts b/src/connectors/chain.ts new file mode 100644 index 0000000..8a1a4c0 --- /dev/null +++ b/src/connectors/chain.ts @@ -0,0 +1,7 @@ +export enum ChainIdentifier { + UNKNOWN = "UNKNOWN", + MAINNET = "MAINNET", + TESTNET = "TESTNET", + DEVNET = "DEVNET", + AMM_DEVNET = "AMM_DEVNET", +} diff --git a/src/connectors/connector.ts b/src/connectors/connector.ts new file mode 100644 index 0000000..ec92310 --- /dev/null +++ b/src/connectors/connector.ts @@ -0,0 +1,22 @@ +import { Provider } from "connectors/provider"; +import { State } from "connectors/state"; + +export abstract class Connector { + public provider?: Provider; + + public readonly state: State; + protected onError?: (error: Error) => void; + + constructor(onError?: (error: Error) => void) { + this.state = new State(); + this.onError = onError; + } + + public reset(): Promise | void { + this.state.reset(); + } + + public abstract activate(...args: unknown[]): Promise | void; + + public deactivate?(...args: unknown[]): Promise | void; +} diff --git a/src/connectors/context.tsx b/src/connectors/context.tsx new file mode 100644 index 0000000..fbe3bd2 --- /dev/null +++ b/src/connectors/context.tsx @@ -0,0 +1,67 @@ +import type { ReactNode } from "react"; +import React from "react"; + +import { Connector } from "connectors/connector"; +import { State } from "connectors/state"; +import type { Provider } from "connectors/provider"; +import { CONNECTORS } from "connectors"; + +export type Web3ContextType = { + connector?: Connector; + provider?: Provider; + chainId: ReturnType; + account: ReturnType; + isActivating: ReturnType; + isActive: ReturnType; +}; + +export const Web3Context = React.createContext( + undefined +); + +export type Web3ProviderProps = { + children: ReactNode; +}; + +export function Web3Provider({ children }: Web3ProviderProps) { + // Note: Calling hooks in a map is okay in this case, because 'CONNECTORS' + // never changes and the same hooks are called each time. + const values = CONNECTORS.map((c) => { + const { useIsActive } = c.state.getHooks(); + // eslint-disable-next-line react-hooks/rules-of-hooks + return useIsActive(); + }); + const index = values.findIndex((isActive) => isActive); + const connector = CONNECTORS[index === -1 ? 0 : index]; + + const provider = connector.provider; + const chainId = connector.state.getChainId(); + const account = connector.state.getAccount(); + const isActivating = connector.state.isActivating(); + const isActive = connector.state.isActive(); + + return ( + + {children} + + ); +} + +export function useWeb3(): Web3ContextType { + const context = React.useContext( + Web3Context as React.Context + ); + if (!context) { + throw Error("useWeb3 can only be used within the Web3Context component"); + } + return context; +} diff --git a/src/connectors/empty.ts b/src/connectors/empty.ts new file mode 100644 index 0000000..c715c3c --- /dev/null +++ b/src/connectors/empty.ts @@ -0,0 +1,13 @@ +import { Connector } from "connectors/connector"; + +export class EmptyWallet extends Connector { + + public async activate(): Promise {} + + public async deactivate(): Promise { + this.provider = undefined; + this.state.reset(); + } +} + +export const empty = new EmptyWallet(); diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts new file mode 100644 index 0000000..eeb4e68 --- /dev/null +++ b/src/connectors/gem.ts @@ -0,0 +1,85 @@ +import { isConnected, getAddress, getNetwork } from "@gemwallet/api"; + +import { Connector } from "connectors/connector"; +import { Provider } from "connectors/provider"; +import { ChainIdentifier } from "./chain"; + +export class NoGemWalletError extends Error { + public constructor() { + super("GemWallet not installed"); + this.name = NoGemWalletError.name; + Object.setPrototypeOf(this, NoGemWalletError.prototype); + } +} + +// TODO +type GemWalletProvider = Provider; +type GemWalletOptions = any; + +export type GemWalletConstructorArgs = { + options?: GemWalletOptions; + onError?: (error: Error) => void; +}; + +export class GemWallet extends Connector { + public provider: GemWalletProvider | undefined; + + private readonly options: GemWalletOptions; + + constructor({ options, onError }: GemWalletConstructorArgs) { + super(onError); + this.options = options; + } + + private mapChainId(network: string): ChainIdentifier { + switch (network.toLowerCase()) { + case "mainnet": + return ChainIdentifier.MAINNET; + case "testnet": + return ChainIdentifier.TESTNET; + case "devnet": + return ChainIdentifier.DEVNET; + case "amm-devnet": + return ChainIdentifier.AMM_DEVNET; + default: + return ChainIdentifier.UNKNOWN; + } + } + + public async activate(): Promise { + const cancelActivation = this.state.startActivation(); + + // TODO create provider + this.provider = undefined; + + try { + const connected = await isConnected(); + if (!connected) { + throw new NoGemWalletError(); + } + + const address = await getAddress(); + if (address === null) { + throw Error("User refused to share GemWallet address"); + } + + const network: string = await getNetwork(); + + this.state.update({ + chainId: this.mapChainId(network), + account: address, + }); + } catch (error) { + cancelActivation(); + this.onError?.(error as Error); + throw error; + } + } + + public async deactivate(): Promise { + this.provider = undefined; + this.state.reset(); + } +} + +export const gem = new GemWallet({}); diff --git a/src/connectors/index.ts b/src/connectors/index.ts new file mode 100644 index 0000000..bcd2410 --- /dev/null +++ b/src/connectors/index.ts @@ -0,0 +1,33 @@ +import { Connector } from "./connector"; +import { empty } from "./empty"; +import { gem } from "./gem"; +import { xumm } from "./xumm"; + +export enum ConnectorType { + EMPTY = "EMPTY", + XUMM = "XUMM", + GEM = "GEM", +} + +export const CONNECTORS = [empty, xumm, gem]; + +export function getConnector(c: Connector | ConnectorType) { + if (c instanceof Connector) { + const connector = CONNECTORS.find((x) => x === c) + if (!connector) { + throw Error("Unsupported connector"); + } + return connector + } else { + switch (c) { + case ConnectorType.EMPTY: + return empty; + case ConnectorType.XUMM: + return xumm; + case ConnectorType.GEM: + return gem; + } + } +} + + diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts new file mode 100644 index 0000000..fdfd015 --- /dev/null +++ b/src/connectors/provider.ts @@ -0,0 +1,12 @@ +// TODO +export abstract class Provider { + + public async sendTransaction(): Promise { + return ""; + } + + public async signMessage(message: string): Promise { + return ""; + } + +} diff --git a/src/connectors/state.ts b/src/connectors/state.ts new file mode 100644 index 0000000..ca4440d --- /dev/null +++ b/src/connectors/state.ts @@ -0,0 +1,122 @@ +import type { StoreApi } from "zustand"; +import { createStore, useStore } from "zustand"; + +import { ChainIdentifier } from "connectors/chain"; + +type ConnectorState = { + chainId?: ChainIdentifier; + account?: string; + activating: boolean; +}; + +type ConnectorStore = StoreApi; + +type ConnectorStateUpdate = + | { + chainId: ChainIdentifier; + account: string; + } + | { + chainId: ChainIdentifier; + account?: never; + } + | { + chainId?: never; + account: string; + }; + +const DEFAULT_STATE = { + chainId: undefined, + account: undefined, + activating: false, +}; + +export class State { + private nullifier: number; + private store: ConnectorStore; + + constructor() { + this.nullifier = 0; + this.store = createStore(() => DEFAULT_STATE); + } + + public startActivation(): () => void { + const nullifierCached = ++this.nullifier; + + this.store.setState({ ...DEFAULT_STATE, activating: true }); + + // return a function that cancels the activation iff nothing else has happened + return () => { + if (this.nullifier === nullifierCached) { + this.store.setState({ activating: false }); + } + }; + } + + public update(stateUpdate: ConnectorStateUpdate): void { + this.nullifier++; + this.store.setState((existingState): ConnectorState => { + const chainId = stateUpdate.chainId ?? existingState.chainId; + const account = stateUpdate.account ?? existingState.account; + + // ensure that the activating flag is cleared when appropriate + let activating = existingState.activating; + if (activating && chainId && account) { + activating = false; + } + + return { chainId, account, activating }; + }); + } + + public reset(): void { + this.nullifier++; + this.store.setState(DEFAULT_STATE); + } + + public getChainId(): ConnectorState["chainId"] { + return this.store.getState().chainId; + } + + public getAccount(): ConnectorState["account"] { + return this.store.getState().account; + } + + public isActivating(): ConnectorState["activating"] { + return this.store.getState().activating; + } + + public isActive(): boolean { + const chainId = this.getChainId(); + const accounts = this.getAccount(); + const activating = this.isActivating(); + + return Boolean(chainId && accounts && !activating); + } + + public getHooks() { + const store = this.store; + + function useChainId(): ConnectorState["chainId"] { + return useStore(store, (s) => s.chainId); + } + + function useAccount(): ConnectorState["account"] { + return useStore(store, (s) => s.account); + } + + function useIsActivating(): ConnectorState["activating"] { + return useStore(store, (s) => s.activating); + } + + function useIsActive(): boolean { + const chainId = useChainId(); + const accounts = useAccount(); + const activating = useIsActivating(); + + return Boolean(chainId && accounts && !activating); + } + + return { useChainId, useAccount, useIsActivating, useIsActive }; + } +} diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts new file mode 100644 index 0000000..cf83c33 --- /dev/null +++ b/src/connectors/xumm.ts @@ -0,0 +1,120 @@ +import { XummPkce } from "xumm-oauth2-pkce"; + +import { Connector } from "connectors/connector"; +import { Provider } from "connectors/provider"; +import config from "config"; +import { ChainIdentifier } from "./chain"; + +// TODO +type XummWalletProvider = Provider; +type XummWalletOptions = ConstructorParameters[1]; + +export type XummWalletConstructorArgs = { + apiKey: string; + options?: XummWalletOptions; + onError?: (error: Error) => void; +}; + +export class XummWallet extends Connector { + public provider: XummWalletProvider | undefined; + + private readonly apiKey: string; + private readonly options: XummWalletOptions; + private wallet: XummPkce | undefined; + + constructor({ apiKey, options, onError }: XummWalletConstructorArgs) { + super(onError); + this.apiKey = apiKey; + this.options = options; + } + + private mapChainId(network: string): ChainIdentifier { + switch (network.toLowerCase()) { + case "mainnet": + return ChainIdentifier.MAINNET; + case "testnet": + return ChainIdentifier.TESTNET; + case "devnet": + return ChainIdentifier.DEVNET; + default: + return ChainIdentifier.UNKNOWN; + } + } + + private async init(): Promise { + this.wallet = new XummPkce(this.apiKey, this.options); + + // TODO create provider + this.provider = undefined; + + this.wallet.on("error", (error) => { + this.onError?.(error); + }); + + this.wallet.on("success", async () => { + const state = await this.wallet?.state(); + if (!state) { + throw Error("Missing Xumm state"); + } + + const { me } = state; + const network = (me as any).networkType as string; + this.state.update({ + chainId: this.mapChainId(network), + account: me.account, + }); + }); + + this.wallet.on("retrieved", async () => { + const state = await this.wallet?.state(); + if (!state) { + return; + } + + const { me } = state; + const network = (me as any).networkType as string; + this.state.update({ + chainId: this.mapChainId(network), + account: me.account, + }); + }); + + this.wallet.on("loggedout", async () => { + this.state.reset(); + }); + } + + public async activate(): Promise { + // only do something, if not already connected + const state = await this.wallet?.state(); + if (state) { + return; + } + + const cancelActivation = this.state.startActivation(); + + try { + await this.init(); + if (!this.wallet) { + throw new Error("No Xumm wallet"); + } + + await this.wallet.authorize(); + } catch (error) { + cancelActivation(); + throw error; + } + } + + public async deactivate(): Promise { + await this.wallet?.logout(); + this.wallet = undefined; + this.provider = undefined; + this.state.reset(); + } +} + +export const xumm = new XummWallet({ + apiKey: config.connector.xumm.apiKey, + options: config.connector.xumm.options, +}); diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..0f7b763 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import reportWebVitals from "./reportWebVitals"; + +const root = ReactDOM.createRoot( + document.getElementById("root") as HTMLElement +); +root.render( + + + +); + +// If you want to start measuring performance in your app, pass a function +// to log results (for example: reportWebVitals(console.log)) +// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals +reportWebVitals(); diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx new file mode 100644 index 0000000..f5dc8e8 --- /dev/null +++ b/src/layouts/MainLayout.tsx @@ -0,0 +1,28 @@ +import { Outlet } from "react-router-dom"; + +import Box from "@mui/material/Box"; +import Container from "@mui/material/Container"; + +import Header from "components/Header"; + +function MainLayout(props: any) { + return ( + +
+ + + + + + + ); +} + +export default MainLayout; diff --git a/src/pages/ErrorPage.tsx b/src/pages/ErrorPage.tsx new file mode 100644 index 0000000..efe4fd2 --- /dev/null +++ b/src/pages/ErrorPage.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { isRouteErrorResponse, useRouteError } from "react-router-dom"; + +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; + +function ErrorPage() { + const error = useRouteError(); + console.error(error); + + return ( + + + Oops! + + + Sorry, an unexpected error has occurred. + + { + isRouteErrorResponse(error) && ( + + {error.statusText || error.statusText} + + ) + } + + ); +} + +export default ErrorPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..d9ece8c --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,52 @@ +import React from "react"; + +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; +import MintDialog from "components/MintDialog"; + +import { useWeb3 } from "connectors/context"; + +function HomePage() { + const { isActive } = useWeb3(); + const [open, setOpen] = React.useState(false); + + const handleClick = (event: React.MouseEvent) => { + setOpen(true); + }; + + return ( + + + + + Dashboard + + {!isActive && ( + + Connect a wallet to continue! + + )} + + + + + + ); +} + +export default HomePage; diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..da75eb5 --- /dev/null +++ b/src/pages/NotFoundPage.tsx @@ -0,0 +1,20 @@ +import Container from "@mui/material/Container"; +import Typography from "@mui/material/Typography"; + +function NotFoundPage() { + return ( + + + 404 Not Found! + + + ); +} + +export default NotFoundPage; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts new file mode 100644 index 0000000..6431bc5 --- /dev/null +++ b/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts new file mode 100644 index 0000000..5fa3583 --- /dev/null +++ b/src/reportWebVitals.ts @@ -0,0 +1,15 @@ +import { ReportHandler } from "web-vitals"; + +const reportWebVitals = (onPerfEntry?: ReportHandler) => { + if (onPerfEntry && onPerfEntry instanceof Function) { + import("web-vitals").then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { + getCLS(onPerfEntry); + getFID(onPerfEntry); + getFCP(onPerfEntry); + getLCP(onPerfEntry); + getTTFB(onPerfEntry); + }); + } +}; + +export default reportWebVitals; diff --git a/src/routes/DefaultRoutes.tsx b/src/routes/DefaultRoutes.tsx new file mode 100644 index 0000000..21534aa --- /dev/null +++ b/src/routes/DefaultRoutes.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import Loadable from "components/Loadable"; + +const NotFoundPage = Loadable(React.lazy(() => import("pages/NotFoundPage"))); + +const DefaultRoutes = { + path: "*", + element: , +}; + +export default DefaultRoutes; diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx new file mode 100644 index 0000000..5dc827e --- /dev/null +++ b/src/routes/MainRoutes.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Navigate } from "react-router-dom"; + +import Loadable from "components/Loadable"; +import MainLayout from "layouts/MainLayout"; + +// const ClaimPage = Loadable(React.lazy(() => import("pages/ClaimPage"))); +const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); +const HomePage = Loadable(React.lazy(() => import("pages/HomePage"))); +// const OverviewPage = Loadable(React.lazy(() => import("pages/OverviewPage"))); +// const VerifyPage = Loadable(React.lazy(() => import("pages/VerifyPage"))); + +const MainRoutes = { + path: "/", + element: , + errorElement: , + children: [ + { + path: "/", + element: , + }, + // { + // path: "/claim", + // element: , + // }, + // { + // path: "/verify", + // element: , + // }, + // { + // path: "/overview", + // element: , + // }, + ], +}; + +export default MainRoutes; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..16cc03f --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,8 @@ +import { useRoutes } from "react-router-dom"; + +import DefaultRoutes from "./DefaultRoutes"; +import MainRoutes from "./MainRoutes"; + +export default function Routes() { + return useRoutes([MainRoutes, DefaultRoutes]); +} diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..1dd407a --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,5 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// allows you to do things like: +// expect(element).toHaveTextContent(/react/i) +// learn more: https://github.com/testing-library/jest-dom +import "@testing-library/jest-dom"; diff --git a/src/states/atoms.ts b/src/states/atoms.ts new file mode 100644 index 0000000..ca7baa4 --- /dev/null +++ b/src/states/atoms.ts @@ -0,0 +1,6 @@ +import { atomWithStorage, createJSONStorage } from "jotai/utils"; + +import { ConnectorType } from "connectors"; + +const storage = createJSONStorage(() => sessionStorage); +export const selectedWalletAtom = atomWithStorage("selected-wallet", ConnectorType.EMPTY, storage); diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 0000000..c64222b --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,16 @@ +// import { isValidAddress } from "xrpl"; + +export const shortenAddress = (address?: string, chars: number = 4): string => { + if (!address) { + return ""; + } + if (address.length <= 2 * chars) { + return address; + } + // if (!isValidAddress(address)) { + // throw Error(`Invalid address '${address}'`); + // } + const start = address.slice(0, chars); + const end = address.slice(-chars); + return `${start}...${end}`; +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3608b36 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "baseUrl": "src", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "node", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "es5" + }, + "include": ["src"] +} From ab338668814c3826063a726e0b2d9f212d2a0269 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 12 Jun 2023 10:45:46 +0200 Subject: [PATCH 002/135] add setup steps to readme --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 94650cd..590a2d6 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,16 @@ The web app for Proof of Attendance Protocol ## Requirements +- Node.js `v18.16.0+` +- Yarn `v1.22.19+` - Running POAP API server (backend), for details see [here](https://github.com/rikublock/POAP-API) +## Getting Started +- install dependencies with `yarn` +- rename `.env.example` to `.env` (change values as needed) +- run the backend +- run the app with `yarn start` + ## Available Scripts In the project directory, you can run: @@ -35,3 +43,7 @@ The build is minified and the filenames include the hashes.\ Your app is ready to be deployed! See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. + +## Supported Wallets +- Xumm (installation details [here](https://xumm.app/)) +- GemWallet (installation details [here](https://gemwallet.app/)) From e18c9278be3e025f2ce578c63bfebb6434222a50 Mon Sep 17 00:00:00 2001 From: Riku Date: Fri, 16 Jun 2023 17:18:12 +0200 Subject: [PATCH 003/135] add event overview, event details page Requires an updated version of the POAP backend --- package.json | 2 + src/apis/index.ts | 6 +- src/apis/poap.ts | 81 ++++++---- src/components/DataTable.tsx | 52 +++++++ src/components/Loader.tsx | 2 +- src/components/MintDialog.tsx | 23 ++- src/pages/EventPage.tsx | 273 ++++++++++++++++++++++++++++++++++ src/pages/HomePage.tsx | 139 +++++++++++++++-- src/routes/MainRoutes.tsx | 13 +- src/states/atoms.ts | 10 +- src/types.ts | 33 ++++ 11 files changed, 576 insertions(+), 58 deletions(-) create mode 100644 src/components/DataTable.tsx create mode 100644 src/pages/EventPage.tsx create mode 100644 src/types.ts diff --git a/package.json b/package.json index bf9e578..d2b486c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "@emotion/styled": "^11.11.0", "@gemwallet/api": "^2.2.1", "@mui/icons-material": "^5.11.16", + "@mui/lab": "^5.0.0-alpha.134", "@mui/material": "^5.13.2", + "@mui/x-data-grid": "^6.7.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", diff --git a/src/apis/index.ts b/src/apis/index.ts index 33fd412..2d35a68 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -3,7 +3,8 @@ import { claim, startVerification, verifyOwnership, - attendees, + event, + events, } from "apis/poap"; const API = { @@ -11,7 +12,8 @@ const API = { claim, startVerification, verifyOwnership, - attendees, + event, + events, }; export default API; diff --git a/src/apis/poap.ts b/src/apis/poap.ts index b2bc401..0410e4e 100644 --- a/src/apis/poap.ts +++ b/src/apis/poap.ts @@ -1,5 +1,6 @@ import axios from "axios"; import config from "config"; +import type { Event } from "types"; export type mintParams = { walletAddress: string; @@ -8,7 +9,7 @@ export type mintParams = { title: string; desc: string; loc: string; -} +}; export type mintResult = { eventId: number; @@ -17,7 +18,7 @@ export type mintResult = { URI: string; title: string; claimable: number; -} +}; export const mint = async (params: mintParams): Promise => { const response = await axios.get( @@ -26,7 +27,7 @@ export const mint = async (params: mintParams): Promise => { responseType: "json", timeout: config.timeout, params: params, - }, + } ); if (response.status === 200) { @@ -41,14 +42,14 @@ export type claimParams = { type: number; minter: string; eventId: string | number; -} +}; export type claimResult = { status: string; result?: any; offer?: any; // TODO -} +}; export const claim = async (params: claimParams): Promise => { const response = await axios.get( @@ -57,7 +58,7 @@ export const claim = async (params: claimParams): Promise => { responseType: "json", timeout: config.timeout, params: params, - }, + } ); if (response.status === 200) { @@ -69,18 +70,20 @@ export const claim = async (params: claimParams): Promise => { export type startVerificationParams = { walletAddress: string; -} +}; export type startVerificationResult = string; -export const startVerification = async (params: startVerificationParams): Promise => { +export const startVerification = async ( + params: startVerificationParams +): Promise => { const response = await axios.get( new URL("/api/startVerification", config.apiURL).toString(), { responseType: "json", timeout: config.timeout, params: params, - }, + } ); if (response.status === 200) { @@ -95,20 +98,22 @@ export type verifyOwnershipParams = { signature: string; minter: string; eventId: string | number; -} +}; export type verifyOwnershipResult = { // TODO -} +}; -export const verifyOwnership = async (params: verifyOwnershipParams): Promise => { +export const verifyOwnership = async ( + params: verifyOwnershipParams +): Promise => { const response = await axios.get( new URL("/api/verifyOwnership", config.apiURL).toString(), { responseType: "json", timeout: config.timeout, params: params, - }, + } ); if (response.status === 200) { @@ -118,32 +123,50 @@ export const verifyOwnership = async (params: verifyOwnershipParams): Promise => { +export const events = async (params: eventsParams): Promise => { const response = await axios.get( - new URL("/api/attendees", config.apiURL).toString(), + new URL("/api/events", config.apiURL).toString(), { responseType: "json", timeout: config.timeout, params: params, - }, + } + ); + + if (response.status === 200) { + return response.data.result as eventsResult; + } + + throw new Error(response.status.toString()); +}; + +export type eventParams = { + id: string | number; + includeAttendees: boolean | string | number; +}; + +export type eventResult = Event | undefined; + +export const event = async (params: eventParams): Promise => { + const { id, includeAttendees } = params; + const response = await axios.get( + new URL(`/api/event/${id}`, config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + params: { includeAttendees }, + } ); if (response.status === 200) { - return response.data.result as attendeesResult; + return response.data.result as eventResult; } throw new Error(response.status.toString()); diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx new file mode 100644 index 0000000..2af142a --- /dev/null +++ b/src/components/DataTable.tsx @@ -0,0 +1,52 @@ +import Box from "@mui/material/Box"; +import { + DataGrid, + GridColDef, + gridClasses, + GridOverlay, +} from "@mui/x-data-grid"; + +type DataTableProps = { + columns: GridColDef[]; + rows: any[]; +}; + +function DataTable(props: DataTableProps) { + const { columns, rows } = props; + return ( + + No data, + }} + /> + + ); +} + +export default DataTable; diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index 40490c4..d73e140 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -6,7 +6,7 @@ function Loader() { return ( - + Loading diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index f73e5de..8c7290f 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -1,8 +1,10 @@ import React from "react"; -import type { ReactNode, Dispatch } from "react"; +import { useAtom } from "jotai"; +import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; import Button from "@mui/material/Button"; +import LoadingButton from "@mui/lab/LoadingButton"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; @@ -15,6 +17,8 @@ import CircularProgress from "@mui/material/CircularProgress"; import API from "apis"; import { useWeb3 } from "connectors/context"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; type MintDialogContent = { title: string; @@ -34,28 +38,31 @@ const DEFAULT_CONTENT = { type MintDialogProps = { children?: ReactNode; - open: boolean; - setOpen: Dispatch; }; function MintDialog(props: MintDialogProps) { - const { open, setOpen } = props; const { account } = useWeb3(); + const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(false); const [content, setContent] = React.useState(DEFAULT_CONTENT); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); + React.useEffect(() => { + setOpen(activeDialog === DialogIdentifier.DIALOG_MINT); + }, [activeDialog]); + const handleClose = (event: {}, reason?: string) => { if (reason === "backdropClick") { return; } - setOpen(false); + setActiveDialog(undefined); }; const handleCancel = (event: React.MouseEvent) => { setContent(DEFAULT_CONTENT); - setOpen(false); + setActiveDialog(undefined); }; const handleConfirm = async (event: React.MouseEvent) => { @@ -84,7 +91,7 @@ function MintDialog(props: MintDialogProps) { setLoading(false); } setContent(DEFAULT_CONTENT); - setOpen(false); + setActiveDialog(undefined); }; const validateChange = (text: string, name: string): boolean => { @@ -189,6 +196,8 @@ function MintDialog(props: MintDialogProps) { onChange={handleChange} disabled={loading} required + multiline + rows={2} /> (); + const [metadata, setMetadata] = React.useState(); + const { enqueueSnackbar } = useSnackbar(); + + const { id } = useParams(); + const navigate = useNavigate(); + + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + const event = await API.event({ + id: id as string, + includeAttendees: true, + }); + + if (mounted) { + setData(event ? event : null); + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Failed to load event data: ${err.message}`, { + variant: "error", + }); + } else { + enqueueSnackbar("Failed to load event data", { + variant: "error", + }); + } + } + }; + + if (id) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [id]); + + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + const response = await axios({ + method: "get", + url: data?.uri, + }); + + if (mounted) { + setMetadata(response.data as Metadata); + } + } catch (err) { + console.error(err); + if (mounted) { + setMetadata(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Failed to load event metadata: ${err.message}`, { + variant: "error", + }); + } else { + enqueueSnackbar("Failed to load event metadata", { + variant: "error", + }); + } + } + }; + + if (data) { + load(); + } else { + setMetadata(undefined); + } + + return () => { + mounted = false; + }; + }, [data]); + + const columns: GridColDef[] = [ + { field: "index", headerName: "#", width: 45, minWidth: 45 }, + { field: "address", headerName: "Wallet Address", width: 320 }, + { field: "name", headerName: "Name", flex: 1 }, + { + field: "email", + headerName: "Email", + align: "center", + sortable: false, + filterable: false, + width: 60, + renderCell: (params) => { + return ( + + {params.row.emailAddress && ( + + + + )} + + ); + }, + }, + ]; + + const makeName = (first?: string, last?: string): string => { + let result = ""; + if (first) { + result += first; + } + if (last) { + result += ` ${last}`; + } + return result.trim(); + }; + + const rows = React.useMemo(() => { + console.log(data); + if (data && data.attendees) { + return data.attendees.map((a, i) => ({ + id: i, + index: i + 1, + address: a.walletAddress, + name: makeName(a.firstName, a.lastName), + emailAddress: a.email, + })); + } else { + return []; + } + }, [data]); + + return ( + + + + {data ? ( + + + + {data.title} + + {`Event #${data.id}`} + + + {metadata ? ( + + + + + {metadata.description} + + + + + + Information: + + + Date: {metadata.date} + + + Location: {metadata.location} + + + Reserved slots: {data.attendees?.length}/ + {metadata.collectionSize} + + + + + + Attendees: + + + + + ) : ( + + )} + + ) : data === null ? ( + Event not found! + ) : ( + + )} + + navigate(-1)} + > + + + + + + ); +} + +export default EventPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index d9ece8c..a56ae16 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,37 +1,148 @@ import React from "react"; +import { useNavigate } from "react-router-dom"; +import axios from "axios"; +import { useSnackbar } from "notistack"; +import { useAtom } from "jotai"; import Box from "@mui/material/Box"; +import Stack from "@mui/material/Stack"; import Button from "@mui/material/Button"; +import Chip from "@mui/material/Chip"; import Paper from "@mui/material/Paper"; import Typography from "@mui/material/Typography"; -import MintDialog from "components/MintDialog"; +import type { GridColDef } from "@mui/x-data-grid"; +import API from "apis"; import { useWeb3 } from "connectors/context"; +import { DialogIdentifier, Event } from "types"; +import MintDialog from "components/MintDialog"; +import Loader from "components/Loader"; +import DataTable from "components/DataTable"; +import { activeDialogAtom } from "states/atoms"; function HomePage() { - const { isActive } = useWeb3(); - const [open, setOpen] = React.useState(false); + const { account, isActive } = useWeb3(); + const [data, setData] = React.useState(undefined); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + const events = await API.events({ limit: 50, includeAttendees: false }); + + if (mounted) { + setData(events); + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Failed to load events data: ${err.message}`, { + variant: "error", + }); + } else { + enqueueSnackbar("Failed to load events data", { + variant: "error", + }); + } + } + }; + + // only update data, if no dialog is open + if (!activeDialog) { + load(); + } + + return () => { + mounted = false; + }; + }, [activeDialog]); + + // TODO + const handleJoin = async (id: number) => { + console.warn(`Joining event ${id}`); + }; + + const columns: GridColDef[] = React.useMemo( + () => [ + { field: "id", headerName: "ID", width: 45, minWidth: 45 }, + { field: "title", headerName: "Title", flex: 1 }, + { field: "address", headerName: "Owner Address", width: 180 }, + { field: "count", headerName: "Slots", width: 60 }, + { + field: "actions", + headerName: "Actions", + sortable: false, + filterable: false, + width: 130, + renderCell: (params) => { + return ( + + handleJoin(params.row.id)} + disabled={!isActive || params.row.address === account} + /> + navigate(`/event/${params.row.id}`)} + /> + + ); + }, + }, + ], + [account, isActive] + ); + + const rows = React.useMemo(() => { + if (data) { + return data.map((event) => ({ + id: event.id, + title: event.title, + address: event.ownerWalletAddress, + count: event.count, + })); + } else { + return []; + } + }, [data]); const handleClick = (event: React.MouseEvent) => { - setOpen(true); + setActiveDialog(DialogIdentifier.DIALOG_MINT); }; return ( - - - Dashboard + + + Overview {!isActive && ( - - Connect a wallet to continue! + + Connect a wallet to continue! (join an existing event or create a + new one) )} + + + Available Events + + {data ? : } + + + + + ); +} + +export default JoinDialog; diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index 8c7290f..8fc2bc1 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -50,19 +50,19 @@ function MintDialog(props: MintDialogProps) { const { enqueueSnackbar } = useSnackbar(); React.useEffect(() => { - setOpen(activeDialog === DialogIdentifier.DIALOG_MINT); + setOpen(activeDialog.type === DialogIdentifier.DIALOG_MINT); }, [activeDialog]); const handleClose = (event: {}, reason?: string) => { if (reason === "backdropClick") { return; } - setActiveDialog(undefined); + setActiveDialog({}); }; const handleCancel = (event: React.MouseEvent) => { setContent(DEFAULT_CONTENT); - setActiveDialog(undefined); + setActiveDialog({}); }; const handleConfirm = async (event: React.MouseEvent) => { @@ -77,7 +77,7 @@ function MintDialog(props: MintDialogProps) { url: content.url, tokenCount: parseInt(content.tokenCount), }); - console.debug("mintResult", result); + console.debug("MintResult", result); enqueueSnackbar(`Mint successful: Event ID #${result.eventId}`, { variant: "success", }); @@ -91,7 +91,7 @@ function MintDialog(props: MintDialogProps) { setLoading(false); } setContent(DEFAULT_CONTENT); - setActiveDialog(undefined); + setActiveDialog({}); }; const validateChange = (text: string, name: string): boolean => { diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index a56ae16..d9f42a2 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -19,6 +19,7 @@ import MintDialog from "components/MintDialog"; import Loader from "components/Loader"; import DataTable from "components/DataTable"; import { activeDialogAtom } from "states/atoms"; +import JoinDialog from "components/JoinDialog"; function HomePage() { const { account, isActive } = useWeb3(); @@ -55,7 +56,7 @@ function HomePage() { }; // only update data, if no dialog is open - if (!activeDialog) { + if (!activeDialog.type) { load(); } @@ -64,9 +65,11 @@ function HomePage() { }; }, [activeDialog]); - // TODO - const handleJoin = async (id: number) => { - console.warn(`Joining event ${id}`); + const handleJoin = async (id: number, title: string) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_JOIN, + data: { eventId: id, title: title }, + }); }; const columns: GridColDef[] = React.useMemo( @@ -89,8 +92,8 @@ function HomePage() { variant="filled" color="primary" size="small" - onClick={() => handleJoin(params.row.id)} - disabled={!isActive || params.row.address === account} + onClick={() => handleJoin(params.row.id, params.row.title)} + disabled={!isActive} /> ) => { - setActiveDialog(DialogIdentifier.DIALOG_MINT); + setActiveDialog({ type: DialogIdentifier.DIALOG_MINT }); }; return ( @@ -156,6 +159,7 @@ function HomePage() { + ); } diff --git a/src/states/atoms.ts b/src/states/atoms.ts index aed2d4f..063c7ec 100644 --- a/src/states/atoms.ts +++ b/src/states/atoms.ts @@ -11,4 +11,9 @@ export const selectedWalletAtom = atomWithStorage( storage ); -export const activeDialogAtom = atom(undefined); +type ActiveDialogAtom = { + type?: DialogIdentifier; + data?: Record; +}; + +export const activeDialogAtom = atom({}); diff --git a/src/types.ts b/src/types.ts index 9403a68..3690e16 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export enum DialogIdentifier { DIALOG_MINT, + DIALOG_JOIN, } export type User = { From 6485cb06d33d1f86738c2f27881305e9b8bcfa65 Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 20 Jun 2023 17:26:45 +0200 Subject: [PATCH 008/135] remove old routes --- src/components/Header.tsx | 2 -- src/routes/MainRoutes.tsx | 10 ---------- 2 files changed, 12 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 2f86196..723e42c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -41,9 +41,7 @@ const StyledList = styled(MuiList)<{ component?: React.ElementType }>( function Header() { const entries: Array<[string, string, boolean]> = [ ["Home", "/", false], - ["Claim", "/claim", true], ["Verify", "/verify", true], - ["Overview", "/overview", true], ]; return ( diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index e7f5fa0..8c66422 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -4,11 +4,9 @@ import { Navigate } from "react-router-dom"; import Loadable from "components/Loadable"; import MainLayout from "layouts/MainLayout"; -// const ClaimPage = Loadable(React.lazy(() => import("pages/ClaimPage"))); const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); const EventPage = Loadable(React.lazy(() => import("pages/EventPage"))); const HomePage = Loadable(React.lazy(() => import("pages/HomePage"))); -// const OverviewPage = Loadable(React.lazy(() => import("pages/OverviewPage"))); // const VerifyPage = Loadable(React.lazy(() => import("pages/VerifyPage"))); const MainRoutes = { @@ -21,17 +19,9 @@ const MainRoutes = { element: , }, // { - // path: "/claim", - // element: , - // }, - // { // path: "/verify", // element: , // }, - // { - // path: "/overview", - // element: , - // }, { path: "/event/:id", element: , From 5f48257c7e1ef5105dd44ad8dd0abbb17b63ff56 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 21 Jun 2023 11:19:11 +0200 Subject: [PATCH 009/135] add profile dialog --- package.json | 3 + src/apis/index.ts | 12 +- src/apis/poap.ts | 77 +++++++++-- src/components/ProfileDialog.tsx | 229 +++++++++++++++++++++++++++++++ src/components/Web3Status.tsx | 13 +- src/config.ts | 2 +- src/layouts/MainLayout.tsx | 17 +++ src/pages/EventPage.tsx | 7 +- src/pages/HomePage.tsx | 9 +- src/types.ts | 2 + 10 files changed, 341 insertions(+), 30 deletions(-) create mode 100644 src/components/ProfileDialog.tsx diff --git a/package.json b/package.json index 7fea58e..277cc7c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@gemwallet/api": "^2.2.1", + "@hookform/resolvers": "^3.1.1", "@mui/icons-material": "^5.11.16", "@mui/lab": "^5.0.0-alpha.134", "@mui/material": "^5.13.2", @@ -25,6 +26,7 @@ "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.45.0", "react-router-dom": "^6.11.2", "react-scripts": "5.0.1", "typescript": "^5.1.3", @@ -32,6 +34,7 @@ "web-vitals": "^2.1.0", "xrpl": "^2.7.0", "xumm": "^1.4.0", + "zod": "^3.21.4", "zustand": "^4.3.8" }, "scripts": { diff --git a/src/apis/index.ts b/src/apis/index.ts index 2d35a68..983b469 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -3,8 +3,10 @@ import { claim, startVerification, verifyOwnership, - event, - events, + getEvent, + getEvents, + getUser, + updateUser } from "apis/poap"; const API = { @@ -12,8 +14,10 @@ const API = { claim, startVerification, verifyOwnership, - event, - events, + getEvent, + getEvents, + getUser, + updateUser, }; export default API; diff --git a/src/apis/poap.ts b/src/apis/poap.ts index 08b1897..c291897 100644 --- a/src/apis/poap.ts +++ b/src/apis/poap.ts @@ -2,7 +2,7 @@ import axios from "axios"; import config from "config"; import type { NFTOffer } from "xrpl/dist/npm/models/common"; -import type { Event } from "types"; +import type { Event, User } from "types"; export type mintParams = { walletAddress: string; @@ -27,7 +27,7 @@ export const mint = async (params: mintParams): Promise => { new URL("/api/mint", config.apiURL).toString(), { responseType: "json", - timeout: config.timeout, + timeout: 600000, params: params, } ); @@ -56,7 +56,7 @@ export const claim = async (params: claimParams): Promise => { new URL("/api/claim", config.apiURL).toString(), { responseType: "json", - timeout: config.timeout, + timeout: 60000, params: params, } ); @@ -123,14 +123,16 @@ export const verifyOwnership = async ( throw new Error(response.status.toString()); }; -export type eventsParams = { +export type getEventsParams = { limit: string | number; includeAttendees: boolean | string | number; }; -export type eventsResult = Event[]; +export type getEventsResult = Event[]; -export const events = async (params: eventsParams): Promise => { +export const getEvents = async ( + params: getEventsParams +): Promise => { const response = await axios.get( new URL("/api/events", config.apiURL).toString(), { @@ -141,20 +143,20 @@ export const events = async (params: eventsParams): Promise => { ); if (response.status === 200) { - return response.data.result as eventsResult; + return response.data.result as getEventsResult; } throw new Error(response.status.toString()); }; -export type eventParams = { +export type getEventParams = { id: string | number; includeAttendees: boolean | string | number; }; -export type eventResult = Event | undefined; +export type getEventResult = Event | undefined; -export const event = async (params: eventParams): Promise => { +export const getEvent = async (params: getEventParams): Promise => { const { id, includeAttendees } = params; const response = await axios.get( new URL(`/api/event/${id}`, config.apiURL).toString(), @@ -166,7 +168,60 @@ export const event = async (params: eventParams): Promise => { ); if (response.status === 200) { - return response.data.result as eventResult; + return response.data.result as getEventResult; + } + + throw new Error(response.status.toString()); +}; + +export type getUserParams = { + walletAddress: string | number; + includeEvents: boolean | string | number; +}; + +export type getUserResult = User | undefined; + +export const getUser = async (params: getUserParams): Promise => { + const { walletAddress, includeEvents } = params; + const response = await axios.get( + new URL(`/api/user/${walletAddress}`, config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + params: { includeEvents }, + } + ); + + if (response.status === 200) { + return response.data.result as getUserResult; + } + + throw new Error(response.status.toString()); +}; + +export type updateUserData = { + walletAddress: string | number; + firstName: string | null; + lastName: string | null; + email: string | null; +}; + +export type updateUserResult = boolean; + +export const updateUser = async ( + data: updateUserData +): Promise => { + const response = await axios.post( + new URL("/api/updateUser", config.apiURL).toString(), + data, + { + responseType: "json", + timeout: config.timeout, + } + ); + + if (response.status === 200) { + return response.data.result as updateUserResult; } throw new Error(response.status.toString()); diff --git a/src/components/ProfileDialog.tsx b/src/components/ProfileDialog.tsx new file mode 100644 index 0000000..16a93f9 --- /dev/null +++ b/src/components/ProfileDialog.tsx @@ -0,0 +1,229 @@ +import React from "react"; +import axios from "axios"; +import { useAtom } from "jotai"; +import type { ReactNode } from "react"; +import { useSnackbar } from "notistack"; +import { useForm, SubmitHandler } from "react-hook-form"; +import { literal, object, string, TypeOf } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import TextField from "@mui/material/TextField"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import CloseIcon from "@mui/icons-material/Close"; +import Stack from "@mui/material/Stack"; +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; + +const schema = object({ + firstName: string().max(64, "First Name must be less than 64 characters"), + lastName: string().max(64, "Last name must be less than 64 characters"), + email: string().email("Email is invalid").optional().or(literal("")), +}); + +type ProfileFormValues = TypeOf; + +const defaultValues: ProfileFormValues = { + firstName: "", + lastName: "", + email: "", +}; + +type ProfileDialogProps = { + children?: ReactNode; +}; + +function ProfileDialog(props: ProfileDialogProps) { + const { account } = useWeb3(); + const [open, setOpen] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + // Note: These values are only loaded ONCE when the component is first mounted! + const loadedValues = async () => { + if (activeDialog.type === DialogIdentifier.DIALOG_PROFILE && account) { + try { + const result = await API.getUser({ + walletAddress: account!, + includeEvents: false, + }); + if (!result) { + throw Error("User not found"); + } + + return { + firstName: result.firstName + ? result.firstName + : defaultValues.firstName, + lastName: result.lastName ? result.lastName : defaultValues.lastName, + email: result.email ? result.email : defaultValues.email, + }; + } catch (error) { + console.debug(error); + if (axios.isAxiosError(error)) { + enqueueSnackbar(`Failed to load profile data: ${error.message}`, { + variant: "error", + }); + } else { + enqueueSnackbar("Failed to load profile data", { + variant: "error", + }); + } + } + } + return defaultValues; + }; + + const { + register, + formState: { errors, isLoading, isDirty, isValid }, + reset, + handleSubmit, + } = useForm({ + mode: "onBlur", + defaultValues: loadedValues, + resolver: zodResolver(schema), + }); + + React.useEffect(() => { + setOpen(activeDialog.type === DialogIdentifier.DIALOG_PROFILE); + }, [activeDialog]); + + const handleClose = (event: {}, reason?: string) => { + if (reason === "backdropClick") { + return; + } + reset(); + setActiveDialog({}); + }; + + const handleCancel = (event: React.MouseEvent) => { + reset(); + setActiveDialog({}); + }; + + const onSubmit: SubmitHandler = async (values) => { + setLoading(true); + try { + // convert empty strings to null + const result = await API.updateUser({ + walletAddress: account!, + firstName: values.firstName ? values.firstName : null, + lastName: values.lastName ? values.lastName : null, + email: values.email ? values.email : null, + }); + + enqueueSnackbar("Profile update successful", { + variant: "success", + }); + } catch (error) { + console.debug(error); + if (axios.isAxiosError(error)) { + enqueueSnackbar(`Profile update failed: ${error.message}`, { + variant: "error", + }); + } else { + enqueueSnackbar("Profile update failed", { + variant: "error", + }); + } + } finally { + setLoading(false); + } + + reset(); + setActiveDialog({}); + }; + + return ( + + + Your Profile + + theme.palette.grey[500], + }} + size="small" + onClick={handleClose} + disabled={loading} + > + + + + + + + + + + + + + + + ); +} + +export default ProfileDialog; diff --git a/src/components/Web3Status.tsx b/src/components/Web3Status.tsx index 8d31ad2..179304c 100644 --- a/src/components/Web3Status.tsx +++ b/src/components/Web3Status.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import { useSnackbar } from "notistack"; import { styled } from "@mui/material/styles"; @@ -9,12 +9,13 @@ import Menu, { MenuProps } from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import { gem } from "connectors/gem"; -import { selectedWalletAtom } from "states/atoms"; +import { activeDialogAtom, selectedWalletAtom } from "states/atoms"; import { shortenAddress } from "utils/strings"; import { useWeb3 } from "connectors/context"; import { xumm } from "connectors/xumm"; import { ConnectorType, getConnector } from "connectors"; import type { Connector } from "connectors/connector"; +import { DialogIdentifier } from "types"; const StyledMenu = styled((props: MenuProps) => ( (DEFAULT_STATUS); const [menuAnchor, setMenuAnchor] = React.useState(null); const { enqueueSnackbar } = useSnackbar(); @@ -61,7 +63,7 @@ function Web3Status() { React.useEffect(() => { if (selectedConnector && !selectedConnector.state.isActive()) { - // Note: Don't eagerly connect to the GemWallet as it currently prompts + // Note: Don't eagerly connect to the GemWallet as it currently prompts // the user for a password every time the page is refreshed. if (selectedConnector !== gem) { selectedConnector.activate(); @@ -105,6 +107,8 @@ function Web3Status() { { variant: "error" } ); } + } else if (action === "profile") { + setActiveDialog({ type: DialogIdentifier.DIALOG_PROFILE }); } else if (action === "xumm") { try { await xumm.activate(); @@ -127,7 +131,7 @@ function Web3Status() { } } }, - [connector, enqueueSnackbar, setSelectedWallet] + [connector, enqueueSnackbar, setSelectedWallet, setActiveDialog] ); return ( @@ -154,6 +158,7 @@ function Web3Status() { open={Boolean(menuAnchor) && isActive} onClose={handleMenuClose} > + handleAction("profile")}>Profile handleAction("disconnect")}> Disconnect diff --git a/src/config.ts b/src/config.ts index 136c8df..fac2836 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ const DEFAULT = { apiURL: process.env.REACT_APP_URL_POAP_API as string, - timeout: 600000, + timeout: 5000, connector: { xumm: { diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index f5dc8e8..a3c22ec 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -1,11 +1,19 @@ import { Outlet } from "react-router-dom"; +import { useAtomValue } from "jotai"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; import Header from "components/Header"; +import MintDialog from "components/MintDialog"; +import JoinDialog from "components/JoinDialog"; +import ProfileDialog from "components/ProfileDialog"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; function MainLayout(props: any) { + const activeDialog = useAtomValue(activeDialogAtom); + return (
@@ -20,6 +28,15 @@ function MainLayout(props: any) { > + + + { + // mounted component every time the dialog is opened to ensure + // the latest values from the database are load + activeDialog.type === DialogIdentifier.DIALOG_PROFILE && ( + + ) + } ); diff --git a/src/pages/EventPage.tsx b/src/pages/EventPage.tsx index a6669b2..b878638 100644 --- a/src/pages/EventPage.tsx +++ b/src/pages/EventPage.tsx @@ -4,13 +4,11 @@ import axios from "axios"; import { useSnackbar } from "notistack"; import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; import IconButton from "@mui/material/IconButton"; import Paper from "@mui/material/Paper"; import Typography from "@mui/material/Typography"; import Link from "@mui/material/Link"; import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined"; -import ArrowBackIosRoundedIcon from "@mui/icons-material/ArrowBackIosRounded"; import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; import type { GridColDef } from "@mui/x-data-grid"; @@ -18,7 +16,6 @@ import API from "apis"; import type { Event, Metadata } from "types"; import Loader from "components/Loader"; import DataTable from "components/DataTable"; -import { UnfoldLess } from "@mui/icons-material"; function EventPage() { const [data, setData] = React.useState(); @@ -33,8 +30,8 @@ function EventPage() { const load = async () => { try { - const event = await API.event({ - id: id as string, + const event = await API.getEvent({ + id: id!, includeAttendees: true, }); diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index d9f42a2..b4182f1 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -15,11 +15,9 @@ import type { GridColDef } from "@mui/x-data-grid"; import API from "apis"; import { useWeb3 } from "connectors/context"; import { DialogIdentifier, Event } from "types"; -import MintDialog from "components/MintDialog"; import Loader from "components/Loader"; import DataTable from "components/DataTable"; import { activeDialogAtom } from "states/atoms"; -import JoinDialog from "components/JoinDialog"; function HomePage() { const { account, isActive } = useWeb3(); @@ -33,7 +31,10 @@ function HomePage() { const load = async () => { try { - const events = await API.events({ limit: 50, includeAttendees: false }); + const events = await API.getEvents({ + limit: 50, + includeAttendees: false, + }); if (mounted) { setData(events); @@ -158,8 +159,6 @@ function HomePage() { - - ); } diff --git a/src/types.ts b/src/types.ts index 3690e16..29377c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ export enum DialogIdentifier { DIALOG_MINT, DIALOG_JOIN, + DIALOG_PROFILE, } export type User = { @@ -32,3 +33,4 @@ export type Metadata = { uri: string, account: string, }; + From 816e2bfa0041f4e816f060804795df9df540dc3c Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 21 Jun 2023 15:18:30 +0200 Subject: [PATCH 010/135] disable close button while loading --- src/components/JoinDialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index 1697b58..ddea43d 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -112,8 +112,6 @@ function JoinDialog(props: JoinDialogProps) { Sign up for Event #{data?.eventId} theme.palette.grey[500], }} size="small" + onClick={handleClose} + disabled={loading} > From 939778101f52d77f7b3531e542376be5e7867dfe Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 21 Jun 2023 20:52:28 +0200 Subject: [PATCH 011/135] improve profile dialog - handle user does not exist - trim values --- src/components/ProfileDialog.tsx | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/components/ProfileDialog.tsx b/src/components/ProfileDialog.tsx index 16a93f9..29f62b9 100644 --- a/src/components/ProfileDialog.tsx +++ b/src/components/ProfileDialog.tsx @@ -4,7 +4,7 @@ import { useAtom } from "jotai"; import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; import { useForm, SubmitHandler } from "react-hook-form"; -import { literal, object, string, TypeOf } from "zod"; +import { object, string, literal, TypeOf } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; import Button from "@mui/material/Button"; @@ -24,9 +24,13 @@ import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; const schema = object({ - firstName: string().max(64, "First Name must be less than 64 characters"), - lastName: string().max(64, "Last name must be less than 64 characters"), - email: string().email("Email is invalid").optional().or(literal("")), + firstName: string() + .max(64, "First Name must be less than 64 characters") + .trim(), + lastName: string() + .max(64, "Last name must be less than 64 characters") + .trim(), + email: string().email("Email is invalid").trim().optional().or(literal("")), }); type ProfileFormValues = TypeOf; @@ -56,16 +60,11 @@ function ProfileDialog(props: ProfileDialogProps) { walletAddress: account!, includeEvents: false, }); - if (!result) { - throw Error("User not found"); - } return { - firstName: result.firstName - ? result.firstName - : defaultValues.firstName, - lastName: result.lastName ? result.lastName : defaultValues.lastName, - email: result.email ? result.email : defaultValues.email, + firstName: result?.firstName ?? defaultValues.firstName, + lastName: result?.lastName ?? defaultValues.lastName, + email: result?.email ?? defaultValues.email, }; } catch (error) { console.debug(error); @@ -217,7 +216,9 @@ function ProfileDialog(props: ProfileDialogProps) { onClick={handleSubmit(onSubmit)} type="submit" startIcon={loading && } - disabled={loading || !Boolean(account) || isLoading || !isDirty || !isValid} + disabled={ + loading || !Boolean(account) || isLoading || !isDirty || !isValid + } > Update From d8a891ee48ecbc7192a5153b8b43a13d7bb4f8e2 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 22 Jun 2023 14:29:39 +0200 Subject: [PATCH 012/135] tweak join dialog - tell the user to look at the wallet to confirm tx --- src/components/JoinDialog.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index ddea43d..b7fee96 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -52,6 +52,10 @@ function JoinDialog(props: JoinDialogProps) { setLoading(true); try { if (provider && account && data?.eventId) { + enqueueSnackbar("Creating NFT claim request: Confirm the transaction in your wallet", { + variant: "warning", + autoHideDuration: 30000, + }); const result = await API.claim({ walletAddress: account, type: 2, @@ -75,7 +79,7 @@ function JoinDialog(props: JoinDialogProps) { } } else if (result.status === "claimed") { enqueueSnackbar(`Sign-up successful: Already claimed NFT`, { - variant: "warning", + variant: "success", }); } else if (result.status === "empty") { enqueueSnackbar(`Sign-up failed: Event is already full`, { From a4f7a8ff7a767852edc8216526b1e2b3b00f6fde Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 22 Jun 2023 12:59:36 +0200 Subject: [PATCH 013/135] add prettier config --- .prettierrc | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .prettierrc diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f3e4bd3 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +tabWidth: 2 +semi: true +singleQuote: false +printWidth: 80 From 8cd47fbb1427a07f6f3f22cddd94b01c46e36407 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 21 Jun 2023 23:39:24 +0200 Subject: [PATCH 014/135] rework mint dialog - strong input/type checking - support for start and end date - make use of new POST /api/mint --- package.json | 1 + src/App.tsx | 10 +- src/apis/poap.ts | 10 +- src/components/MintDialog.tsx | 256 +++++++++++++++++++++++----------- src/pages/EventPage.tsx | 5 +- src/types.ts | 6 +- 6 files changed, 197 insertions(+), 91 deletions(-) diff --git a/package.json b/package.json index 277cc7c..4e2a2ca 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "axios": "^1.4.0", + "date-fns": "^2.30.0", "jotai": "^2.1.0", "notistack": "^3.0.1", "react": "^18.2.0", diff --git a/src/App.tsx b/src/App.tsx index a72832d..1e40cc1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,14 @@ import { BrowserRouter } from "react-router-dom"; import { Provider as JotaiProvider } from "jotai"; import { SnackbarProvider } from "notistack"; -import Routes from "routes"; import { createTheme, ThemeProvider } from "@mui/material/styles"; import { CssBaseline } from "@mui/material"; import Slide from "@mui/material/Slide"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import Routes from "routes"; import { Web3Provider } from "connectors/context"; function App() { @@ -23,8 +25,10 @@ function App() { TransitionComponent={Slide} maxSnack={3} > - - + + + + diff --git a/src/apis/poap.ts b/src/apis/poap.ts index c291897..b3911fd 100644 --- a/src/apis/poap.ts +++ b/src/apis/poap.ts @@ -4,13 +4,15 @@ import type { NFTOffer } from "xrpl/dist/npm/models/common"; import type { Event, User } from "types"; -export type mintParams = { +export type mintData = { walletAddress: string; tokenCount: number; url: string; title: string; desc: string; loc: string; + dateStart: Date, + dateEnd: Date, }; export type mintResult = { @@ -22,13 +24,13 @@ export type mintResult = { claimable: number; }; -export const mint = async (params: mintParams): Promise => { - const response = await axios.get( +export const mint = async (data: mintData): Promise => { + const response = await axios.post( new URL("/api/mint", config.apiURL).toString(), + data, { responseType: "json", timeout: 600000, - params: params, } ); diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index 8fc2bc1..1a4155b 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -2,9 +2,16 @@ import React from "react"; import { useAtom } from "jotai"; import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; +import { + useForm, + SubmitHandler, + Controller, + DefaultValues, +} from "react-hook-form"; +import { object, string, number, date, intersection, TypeOf } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; import Button from "@mui/material/Button"; -import LoadingButton from "@mui/lab/LoadingButton"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; @@ -14,26 +21,81 @@ import IconButton from "@mui/material/IconButton"; import CloseIcon from "@mui/icons-material/Close"; import Stack from "@mui/material/Stack"; import CircularProgress from "@mui/material/CircularProgress"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import API from "apis"; import { useWeb3 } from "connectors/context"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; -type MintDialogContent = { - title: string; - description: string; - location: string; - url: string; - tokenCount: string; -}; +const schemaCommon = object({ + title: string() + .nonempty("Title is required") + .max(256, "Title must be less than 256 characters") + .trim(), + description: string() + .nonempty("Description is required") + .max(10000, "Description must be less than 10000 characters"), + location: string() + .nonempty("Location is required") + .max(256, "Location must be less than 256 characters"), + url: string().nonempty("URL is required").url("URL is invalid").trim(), + tokenCount: number() + .int() + .positive() + .max(200, "Token count must be less than or equal to 200"), +}); + +// Note: We allow nullable for the DatePicker component to work. +// The final value will always be a valid Date. +const schemaDates = object({ + dateStart: date() + .min(new Date("1900-01-01"), "Date is too far back") + .nullable() + .transform((value, ctx) => { + if (value == null) + ctx.addIssue({ + code: "custom", + message: "Start Date is required", + }); + return value; + }), + dateEnd: date() + .min(new Date("1900-01-01"), "Date is too far back") + .nullable() + .transform((value, ctx) => { + if (value == null) + ctx.addIssue({ + code: "custom", + message: "End Date is required", + }); + return value; + }), +}).refine( + (data) => { + if (data.dateEnd && data.dateStart) { + return data.dateEnd >= data.dateStart; + } + return false; + }, + { + path: ["dateEnd"], + message: "End Date must be later than Start Date", + } +); + +const schema = intersection(schemaCommon, schemaDates); + +type MintFormValues = TypeOf; -const DEFAULT_CONTENT = { +const defaultValues: DefaultValues = { title: "", description: "", location: "", url: "", - tokenCount: "", + dateStart: null, + dateEnd: null, + tokenCount: undefined, }; type MintDialogProps = { @@ -44,11 +106,23 @@ function MintDialog(props: MintDialogProps) { const { account } = useWeb3(); const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(false); - const [content, setContent] = - React.useState(DEFAULT_CONTENT); + // const [content, setContent] = + // React.useState(DEFAULT_CONTENT); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); + const { + control, + register, + formState: { errors, isLoading, isDirty, isValid }, + reset, + handleSubmit, + } = useForm({ + mode: "all", + defaultValues: defaultValues, + resolver: zodResolver(schema), + }); + React.useEffect(() => { setOpen(activeDialog.type === DialogIdentifier.DIALOG_MINT); }, [activeDialog]); @@ -61,21 +135,25 @@ function MintDialog(props: MintDialogProps) { }; const handleCancel = (event: React.MouseEvent) => { - setContent(DEFAULT_CONTENT); + reset(); setActiveDialog({}); }; - const handleConfirm = async (event: React.MouseEvent) => { + const onSubmit: SubmitHandler = async (values) => { + console.log("submit", values); + setLoading(true); try { if (account) { const result = await API.mint({ walletAddress: account, - title: content.title, - desc: content.description, - loc: content.location, - url: content.url, - tokenCount: parseInt(content.tokenCount), + tokenCount: values.tokenCount, + title: values.title, + desc: values.description, + loc: values.location, + url: values.url, + dateStart: values.dateStart!, + dateEnd: values.dateEnd!, }); console.debug("MintResult", result); enqueueSnackbar(`Mint successful: Event ID #${result.eventId}`, { @@ -90,53 +168,11 @@ function MintDialog(props: MintDialogProps) { } finally { setLoading(false); } - setContent(DEFAULT_CONTENT); - setActiveDialog({}); - }; - - const validateChange = (text: string, name: string): boolean => { - if (text === "") { - return true; - } - if (name === "tokenCount") { - return parseInt(text) > 0; - } else { - return true; - } - }; - - const handleChange = ( - event: React.ChangeEvent - ) => { - const value = event.target.value; - const name = event.target.name; - if (validateChange(value, name)) { - setContent({ ...content, [name]: value }); - } + reset(); + setActiveDialog({}); }; - // TODO better validation, for each input separately - const validateContent = React.useCallback(() => { - if (content.title.length === 0) { - return false; - } - if (content.description.length === 0) { - return false; - } - if (content.location.length === 0) { - return false; - } - if (content.url.length === 0) { - // TODO regexp match - return false; - } - if (!(parseInt(content.tokenCount) > 0)) { - return false; - } - return true; - }, [content]); - return ( - + + + ( + + )} + /> + ( + + )} + /> + @@ -225,9 +319,9 @@ function MintDialog(props: MintDialogProps) { diff --git a/src/pages/EventPage.tsx b/src/pages/EventPage.tsx index b878638..fd42605 100644 --- a/src/pages/EventPage.tsx +++ b/src/pages/EventPage.tsx @@ -220,7 +220,10 @@ function EventPage() { Information: - Date: {metadata.date} + Date Start: {metadata.dateStart} + + + Date End: {metadata.dateEnd} Location: {metadata.location} diff --git a/src/types.ts b/src/types.ts index 29377c2..6db2978 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,7 +17,8 @@ export type Event = { title: string; uri: string; count: number; - date: string; + dateStart: string; + dateEnd: string; networkId: number; ownerWalletAddress: string; owner?: User; @@ -29,7 +30,8 @@ export type Metadata = { description: string, collectionSize: number, location: string, - date: string, + dateStart: string, + dateEnd: string, uri: string, account: string, }; From 211bc944a866def84cac29c69cde6b57efb25951 Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 25 Jun 2023 08:52:27 +0200 Subject: [PATCH 015/135] mint dialog clean up --- src/components/MintDialog.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index 1a4155b..1f8eec9 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -106,15 +106,13 @@ function MintDialog(props: MintDialogProps) { const { account } = useWeb3(); const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(false); - // const [content, setContent] = - // React.useState(DEFAULT_CONTENT); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); const { control, register, - formState: { errors, isLoading, isDirty, isValid }, + formState: { errors, isValid }, reset, handleSubmit, } = useForm({ @@ -140,8 +138,6 @@ function MintDialog(props: MintDialogProps) { }; const onSubmit: SubmitHandler = async (values) => { - console.log("submit", values); - setLoading(true); try { if (account) { @@ -190,8 +186,6 @@ function MintDialog(props: MintDialogProps) { Create new Event theme.palette.grey[500], }} size="small" + onClick={handleClose} + disabled={loading} > From b2156cf7d47d44f098654e45cf16b6440de902b3 Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 25 Jun 2023 08:54:59 +0200 Subject: [PATCH 016/135] add connectivity status --- src/apis/auth.ts | 20 +++++++++ src/apis/index.ts | 4 +- src/components/ConnectivityStatus.tsx | 63 +++++++++++++++++++++++++++ src/components/Header.tsx | 2 + src/components/NetworkStatus.tsx | 2 +- src/layouts/MainLayout.tsx | 2 +- 6 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/apis/auth.ts create mode 100644 src/components/ConnectivityStatus.tsx diff --git a/src/apis/auth.ts b/src/apis/auth.ts new file mode 100644 index 0000000..2659ca4 --- /dev/null +++ b/src/apis/auth.ts @@ -0,0 +1,20 @@ +import axios from "axios"; +import config from "config"; + +export type heartbeatResult = boolean + +export const heartbeat = async (): Promise => { + const response = await axios.get( + new URL("/heartbeat", config.apiURL).toString(), + { + responseType: "json", + timeout: config.timeout, + } + ); + + if (response.status === 200) { + return response.data.result as heartbeatResult; + } + + throw new Error(response.status.toString()); +}; diff --git a/src/apis/index.ts b/src/apis/index.ts index 983b469..958f98d 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,3 +1,4 @@ +import { heartbeat } from "./auth"; import { mint, claim, @@ -6,10 +7,11 @@ import { getEvent, getEvents, getUser, - updateUser + updateUser, } from "apis/poap"; const API = { + heartbeat, mint, claim, startVerification, diff --git a/src/components/ConnectivityStatus.tsx b/src/components/ConnectivityStatus.tsx new file mode 100644 index 0000000..a5af8c8 --- /dev/null +++ b/src/components/ConnectivityStatus.tsx @@ -0,0 +1,63 @@ +import React from "react"; + +import Alert from "@mui/material/Alert"; +import CloseIcon from "@mui/icons-material/Close"; +import Collapse from "@mui/material/Collapse"; +import IconButton from "@mui/material/IconButton"; + +import API from "apis"; + +function ConnectivityStatus() { + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + let mounted = true; + + const check = async () => { + try { + await API.heartbeat(); + if (mounted) { + setOpen(false); + } + } catch (error) { + if (mounted) { + setOpen(true); + } + } + }; + + const interval = setInterval(check, 30 * 1000); + + check(); + + // clear interval to prevent memory leaks when unmounting + return () => { + clearInterval(interval); + mounted = false; + }; + }, []); + + return ( + + { + setOpen(false); + }} + > + + + } + sx={{ "& .MuiAlert-icon": { marginLeft: "auto" } }} + > + API backend service is currently unavailable! + + + ); +} + +export default ConnectivityStatus; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 723e42c..ca253b7 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -14,6 +14,7 @@ import Stack from "@mui/material/Stack"; import Web3Status from "components/Web3Status"; import NetworkStatus from "components/NetworkStatus"; +import ConnectivityStatus from "./ConnectivityStatus"; const StyledList = styled(MuiList)<{ component?: React.ElementType }>( ({ theme }) => ({ @@ -54,6 +55,7 @@ function Header() { color="inherit" position="fixed" > + diff --git a/src/components/NetworkStatus.tsx b/src/components/NetworkStatus.tsx index d3eec8b..6c1d0d4 100644 --- a/src/components/NetworkStatus.tsx +++ b/src/components/NetworkStatus.tsx @@ -25,9 +25,9 @@ function NetworkStatus() { + + {({ TransitionProps }) => ( + + + + + + + + + toggleAuto()} + /> + } + label="Automatic Login" + /> + + + + {isAuthenticated && ( + + + + { + setOpen(false); + setActiveDialog({ + type: DialogIdentifier.DIALOG_PROFILE, + }); + }} + > + + + + + + + )} + + + {isAuthenticated ? ( + logout()}> + + + + + + ) : ( + { + setOpen(false); + handleLogin(); + }} + > + + + + + + )} + + + + + + )} + + + ); +} + +export default AuthStatus; diff --git a/src/components/ConnectivityStatus.tsx b/src/components/ConnectivityStatus.tsx index cd8ad1a..66d225b 100644 --- a/src/components/ConnectivityStatus.tsx +++ b/src/components/ConnectivityStatus.tsx @@ -5,38 +5,15 @@ import CloseIcon from "@mui/icons-material/Close"; import Collapse from "@mui/material/Collapse"; import IconButton from "@mui/material/IconButton"; -import API from "apis"; +import { useAuth } from "components/AuthContext"; function ConnectivityStatus() { + const { isAvailable } = useAuth(); const [open, setOpen] = React.useState(false); React.useEffect(() => { - let mounted = true; - - // TODO does the mounted value actually change ? - const check = async () => { - try { - await API.auth.heartbeat(); - if (mounted) { - setOpen(false); - } - } catch (error) { - if (mounted) { - setOpen(true); - } - } - }; - - const interval = setInterval(check, 30 * 1000); - - check(); - - // clear interval to prevent memory leaks when unmounting - return () => { - clearInterval(interval); - mounted = false; - }; - }, []); + setOpen(!isAvailable); + }, [isAvailable]); return ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ca253b7..1dd51e9 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -14,7 +14,9 @@ import Stack from "@mui/material/Stack"; import Web3Status from "components/Web3Status"; import NetworkStatus from "components/NetworkStatus"; -import ConnectivityStatus from "./ConnectivityStatus"; +import ConnectivityStatus from "components/ConnectivityStatus"; +import AuthStatus from "components/AuthStatus"; +import { useWeb3 } from "connectors/context"; const StyledList = styled(MuiList)<{ component?: React.ElementType }>( ({ theme }) => ({ @@ -40,10 +42,17 @@ const StyledList = styled(MuiList)<{ component?: React.ElementType }>( ) as typeof MuiList; function Header() { - const entries: Array<[string, string, boolean]> = [ - ["Home", "/", false], - ["Verify", "/verify", true], - ]; + const { isActive } = useWeb3(); + + const entries: Array<[string, string, boolean]> = React.useMemo( + () => [ + ["Home", "/", false], + ["Events", "/events", !isActive], + ["Signups", "/offers", !isActive], + ["Debug", "/debug", false], + ], + [isActive] + ); return ( - + ))} @@ -76,6 +88,7 @@ function Header() { + diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index b7fee96..b6ce07b 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -4,12 +4,16 @@ import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; import Button from "@mui/material/Button"; +import Checkbox from "@mui/material/Checkbox"; import Dialog from "@mui/material/Dialog"; import DialogActions from "@mui/material/DialogActions"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; import DialogTitle from "@mui/material/DialogTitle"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import FormGroup from "@mui/material/FormGroup"; import IconButton from "@mui/material/IconButton"; +import Typography from "@mui/material/Typography"; import CloseIcon from "@mui/icons-material/Close"; import CircularProgress from "@mui/material/CircularProgress"; @@ -27,6 +31,7 @@ type JoinDialogData = Record; function JoinDialog(props: JoinDialogProps) { const { provider, account } = useWeb3(); const [open, setOpen] = React.useState(false); + const [checked, setChecked] = React.useState(true); const [data, setData] = React.useState(); const [loading, setLoading] = React.useState(false); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); @@ -52,39 +57,38 @@ function JoinDialog(props: JoinDialogProps) { setLoading(true); try { if (provider && account && data?.eventId) { - enqueueSnackbar("Creating NFT claim request: Confirm the transaction in your wallet", { - variant: "warning", - autoHideDuration: 30000, - }); - const result = await API.claim({ + const result = await API.event.claim({ walletAddress: account, - type: 2, eventId: data.eventId, }); console.debug("ClaimResult", result); - if (result.status === "transferred" && result.offer) { - const success = await provider.acceptOffer( - result.offer.nft_offer_index - ); - - if (success) { - enqueueSnackbar(`Sign-up successful: Event #${data.eventId}`, { - variant: "success", - }); - } else { - enqueueSnackbar(`Sign-up failed: Unable to claim NFT`, { - variant: "error", - }); + // TODO give user better feedback when already joined an event + if (!result.claimed) { + if (checked) { + enqueueSnackbar( + "Creating NFT claim request: Confirm the transaction in your wallet", + { + variant: "warning", + autoHideDuration: 30000, + } + ); + const success = await provider.acceptOffer(result.offerIndex); + + if (success) { + enqueueSnackbar(`Sign-up successful: Event #${data.eventId}`, { + variant: "success", + }); + } else { + enqueueSnackbar(`Sign-up failed: Unable to claim NFT`, { + variant: "error", + }); + } } - } else if (result.status === "claimed") { + } else { enqueueSnackbar(`Sign-up successful: Already claimed NFT`, { variant: "success", }); - } else if (result.status === "empty") { - enqueueSnackbar(`Sign-up failed: Event is already full`, { - variant: "error", - }); } } } catch (error) { @@ -99,6 +103,10 @@ function JoinDialog(props: JoinDialogProps) { setActiveDialog({}); }; + const handleChange = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + }; + return ( + + + } + label={ + + Immediately claim NFT + + } + /> + diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index 95625a2..215d71a 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -171,6 +171,7 @@ function MintDialog(props: MintDialogProps) { enqueueSnackbar(`Mint successful: Event ID #${result.eventId}`, { variant: "success", }); + reset(); } } catch (error) { console.debug(error); @@ -179,10 +180,8 @@ function MintDialog(props: MintDialogProps) { }); } finally { setLoading(false); + setActiveDialog({}); } - - reset(); - setActiveDialog({}); }; return ( diff --git a/src/components/ProfileDialog.tsx b/src/components/ProfileDialog.tsx index 29f62b9..6ba50be 100644 --- a/src/components/ProfileDialog.tsx +++ b/src/components/ProfileDialog.tsx @@ -22,6 +22,7 @@ import API from "apis"; import { useWeb3 } from "connectors/context"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; +import { useAuth } from "components/AuthContext"; const schema = object({ firstName: string() @@ -47,6 +48,7 @@ type ProfileDialogProps = { function ProfileDialog(props: ProfileDialogProps) { const { account } = useWeb3(); + const { isAuthenticated, jwt } = useAuth(); const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(false); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); @@ -54,10 +56,13 @@ function ProfileDialog(props: ProfileDialogProps) { // Note: These values are only loaded ONCE when the component is first mounted! const loadedValues = async () => { - if (activeDialog.type === DialogIdentifier.DIALOG_PROFILE && account) { + if ( + activeDialog.type === DialogIdentifier.DIALOG_PROFILE && + account && + jwt + ) { try { - const result = await API.getUser({ - walletAddress: account!, + const result = await API.user.getInfo(jwt, { includeEvents: false, }); @@ -113,17 +118,17 @@ function ProfileDialog(props: ProfileDialogProps) { const onSubmit: SubmitHandler = async (values) => { setLoading(true); try { - // convert empty strings to null - const result = await API.updateUser({ - walletAddress: account!, - firstName: values.firstName ? values.firstName : null, - lastName: values.lastName ? values.lastName : null, - email: values.email ? values.email : null, - }); - - enqueueSnackbar("Profile update successful", { - variant: "success", - }); + if (jwt) { + // convert empty strings to null + await API.user.update(jwt, { + firstName: values.firstName ? values.firstName : null, + lastName: values.lastName ? values.lastName : null, + email: values.email ? values.email : null, + }); + enqueueSnackbar("Profile update successful", { + variant: "success", + }); + } } catch (error) { console.debug(error); if (axios.isAxiosError(error)) { @@ -217,7 +222,12 @@ function ProfileDialog(props: ProfileDialogProps) { type="submit" startIcon={loading && } disabled={ - loading || !Boolean(account) || isLoading || !isDirty || !isValid + loading || + !Boolean(account) || + isLoading || + !isDirty || + !isValid || + !isAuthenticated } > Update diff --git a/src/components/Web3Status.tsx b/src/components/Web3Status.tsx index ebb21e0..8e52af6 100644 --- a/src/components/Web3Status.tsx +++ b/src/components/Web3Status.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useAtom, useSetAtom } from "jotai"; +import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; import { styled } from "@mui/material/styles"; @@ -9,7 +9,7 @@ import Menu, { MenuProps } from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import { gem } from "connectors/gem"; -import { activeDialogAtom, selectedWalletAtom } from "states/atoms"; +import { selectedWalletAtom } from "states/atoms"; import { shortenAddress } from "utils/strings"; import { useWeb3 } from "connectors/context"; import { xumm } from "connectors/xumm"; @@ -46,7 +46,6 @@ const DEFAULT_STATUS = "CONNECT WALLET"; function Web3Status() { const { connector, account, isActive } = useWeb3(); const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); - const setActiveDialog = useSetAtom(activeDialogAtom); const [status, setStatus] = React.useState(DEFAULT_STATUS); const [menuAnchor, setMenuAnchor] = React.useState(null); const { enqueueSnackbar } = useSnackbar(); @@ -107,8 +106,6 @@ function Web3Status() { { variant: "error" } ); } - } else if (action === "profile") { - setActiveDialog({ type: DialogIdentifier.DIALOG_PROFILE }); } else if (action === "xumm") { try { await xumm.activate(); @@ -131,7 +128,7 @@ function Web3Status() { } } }, - [connector, enqueueSnackbar, setSelectedWallet, setActiveDialog] + [connector, enqueueSnackbar, setSelectedWallet] ); return ( @@ -158,7 +155,6 @@ function Web3Status() { open={Boolean(menuAnchor) && isActive} onClose={handleMenuClose} > - handleAction("profile")}>Profile handleAction("disconnect")}> Disconnect diff --git a/src/config.ts b/src/config.ts index fac2836..cef52b0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,8 @@ const DEFAULT = { apiURL: process.env.REACT_APP_URL_POAP_API as string, timeout: 5000, + storage: window.sessionStorage, + connector: { xumm: { apiKey: process.env.REACT_APP_KEY_XUMM_API as string, diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index aa31531..8f11b02 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -1,14 +1,16 @@ import { - Network, + acceptNFTOffer, getAddress, getNetwork, + getPublicKey, isInstalled, + Network, signMessage, - acceptNFTOffer, } from "@gemwallet/api"; +import API from "apis"; import { Connector } from "connectors/connector"; -import { Provider } from "connectors/provider"; +import { AuthData, Provider } from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; export class NoGemWalletError extends Error { @@ -19,7 +21,18 @@ export class NoGemWalletError extends Error { } } +export type GemAuthData = AuthData & { + signature: string; +}; + export class GemWalletProvider extends Provider { + private authData: GemAuthData; + + constructor(authData: GemAuthData) { + super(); + this.authData = authData; + } + public async signMessage(message: string): Promise { const signed = await signMessage(message); if (signed.type === "reject") { @@ -43,6 +56,10 @@ export class GemWalletProvider extends Provider { return Boolean(response.result?.hash); } + + public getAuthData(): GemAuthData { + return this.authData; + } } type GemWalletOptions = any; @@ -91,8 +108,6 @@ export class GemWallet extends Connector { throw new NoGemWalletError(); } - this.provider = new GemWalletProvider(); - const address = await getAddress(); if (address.type === "reject" || !address.result) { throw Error("User refused to share GemWallet address"); @@ -103,6 +118,25 @@ export class GemWallet extends Connector { throw Error("User refused to share network"); } + const pubkey = await getPublicKey(); + if (pubkey.type === "reject" || !pubkey.result) { + throw Error("User refused to share public key"); + } + + const tempJwt = await API.auth.nonce({ + pubkey: pubkey.result.publicKey, + }); + + const signed = await signMessage(`backend authentication: ${tempJwt}`); + if (signed.type === "reject" || !signed.result) { + throw Error("User refused to sign auth message"); + } + + this.provider = new GemWalletProvider({ + tempJwt: tempJwt, + signature: signed.result.signedMessage, + }); + this.state.update({ networkId: this.mapNetworkId(network.result.network), account: address.result.address, diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts index 7173a05..a66a3f9 100644 --- a/src/connectors/provider.ts +++ b/src/connectors/provider.ts @@ -1,4 +1,9 @@ +export type AuthData = { + tempJwt: string; +}; + export abstract class Provider { public abstract signMessage(message: string): Promise | string; public abstract acceptOffer(offerIndex: string): Promise | boolean; + public abstract getAuthData(): Promise | AuthData; } diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 51c2620..a5b6afe 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -3,7 +3,7 @@ import { XummPkce } from "xumm-oauth2-pkce"; import config from "config"; import { Connector } from "connectors/connector"; -import { Provider } from "connectors/provider"; +import { AuthData, Provider } from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; export class XummWalletProvider extends Provider { @@ -46,8 +46,10 @@ export class XummWalletProvider extends Provider { return Boolean(result); } - public getJwt() { - return this.jwt; + public getAuthData(): AuthData { + return { + tempJwt: this.jwt, + }; } } diff --git a/src/pages/EventPage.tsx b/src/pages/EventInfoPage.tsx similarity index 98% rename from src/pages/EventPage.tsx rename to src/pages/EventInfoPage.tsx index c6a5a12..734d4e3 100644 --- a/src/pages/EventPage.tsx +++ b/src/pages/EventInfoPage.tsx @@ -17,7 +17,7 @@ import type { Event, Metadata } from "types"; import Loader from "components/Loader"; import DataTable from "components/DataTable"; -function EventPage() { +function EventInfoPage() { const [data, setData] = React.useState(); const [metadata, setMetadata] = React.useState(); const { enqueueSnackbar } = useSnackbar(); @@ -31,7 +31,7 @@ function EventPage() { const load = async () => { try { const event = await API.getEvent({ - id: id!, + id: parseInt(id!), includeAttendees: true, }); @@ -203,7 +203,7 @@ function EventPage() { marginBottom: "0.75rem", }} alt="event banner" - src={metadata.uri} + src={metadata.imageUrl} /> {metadata.description} @@ -269,4 +269,4 @@ function EventPage() { ); } -export default EventPage; +export default EventInfoPage; diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx new file mode 100644 index 0000000..960983f --- /dev/null +++ b/src/pages/EventsPage.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; + +function EventsPage() { + return ( + + + + + Owned Events: + + + + + ); +} + +export default EventsPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index b4182f1..da205a5 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -14,13 +14,13 @@ import type { GridColDef } from "@mui/x-data-grid"; import API from "apis"; import { useWeb3 } from "connectors/context"; -import { DialogIdentifier, Event } from "types"; +import { DialogIdentifier, Event, NetworkIdentifier } from "types"; import Loader from "components/Loader"; import DataTable from "components/DataTable"; import { activeDialogAtom } from "states/atoms"; function HomePage() { - const { account, isActive } = useWeb3(); + const { account, isActive, networkId } = useWeb3(); const [data, setData] = React.useState(undefined); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); @@ -32,8 +32,8 @@ function HomePage() { const load = async () => { try { const events = await API.getEvents({ + networkId: networkId?? NetworkIdentifier.UNKNOWN, limit: 50, - includeAttendees: false, }); if (mounted) { @@ -64,7 +64,7 @@ function HomePage() { return () => { mounted = false; }; - }, [activeDialog]); + }, [activeDialog, isActive]); const handleJoin = async (id: number, title: string) => { setActiveDialog({ @@ -117,7 +117,7 @@ function HomePage() { id: event.id, title: event.title, address: event.ownerWalletAddress, - count: event.count, + count: event.tokenCount, })); } else { return []; @@ -143,7 +143,7 @@ function HomePage() { )} - Available Events + Public Events {data ? : } diff --git a/src/pages/OffersPage.tsx b/src/pages/OffersPage.tsx new file mode 100644 index 0000000..64f4f02 --- /dev/null +++ b/src/pages/OffersPage.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; + +function OffersPage() { + return ( + + + + + Event Attendances: + + + + + ); +} + +export default OffersPage; diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 8c66422..72f2c82 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -4,10 +4,12 @@ import { Navigate } from "react-router-dom"; import Loadable from "components/Loadable"; import MainLayout from "layouts/MainLayout"; +const DebugPage = Loadable(React.lazy(() => import("pages/DebugPage"))); const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); -const EventPage = Loadable(React.lazy(() => import("pages/EventPage"))); +const EventInfoPage = Loadable(React.lazy(() => import("pages/EventInfoPage"))); +const EventsPage = Loadable(React.lazy(() => import("pages/EventsPage"))); const HomePage = Loadable(React.lazy(() => import("pages/HomePage"))); -// const VerifyPage = Loadable(React.lazy(() => import("pages/VerifyPage"))); +const OffersPage = Loadable(React.lazy(() => import("pages/OffersPage"))); const MainRoutes = { path: "/", @@ -18,13 +20,21 @@ const MainRoutes = { path: "/", element: , }, - // { - // path: "/verify", - // element: , - // }, { path: "/event/:id", - element: , + element: , + }, + { + path: "/events", + element: , + }, + { + path: "/offers", + element: , + }, + { + path: "/debug", + element: , }, ], }; diff --git a/src/states/atoms.ts b/src/states/atoms.ts index 9e18094..f78e24f 100644 --- a/src/states/atoms.ts +++ b/src/states/atoms.ts @@ -1,9 +1,11 @@ import { atom } from "jotai"; import { atomWithStorage, createJSONStorage } from "jotai/utils"; +import config from "config"; import { ConnectorType, DialogIdentifier } from "types"; -const storage = createJSONStorage(() => sessionStorage); +const storage = createJSONStorage(() => config.storage); + export const selectedWalletAtom = atomWithStorage( "selected-wallet", ConnectorType.EMPTY, diff --git a/src/types.ts b/src/types.ts index 3e27890..6bbd00d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,8 @@ export enum DialogIdentifier { - DIALOG_MINT, + DIALOG_ADD, + DIALOG_CLAIM, DIALOG_JOIN, + DIALOG_MINT, DIALOG_PROFILE, } @@ -36,17 +38,25 @@ export type Event = { networkId: number; title: string; description: string; - location: string, + location: string; uri: string; tokenCount: number; dateStart: string; dateEnd: string; - isManaged: boolean, + isManaged: boolean; ownerWalletAddress: string; owner?: User; attendees?: User[]; }; +export type Offer = { + id: number; + ownerWalletAddress: string; + tokenId: number; + offerIndex: string; + claimed: boolean; +}; + export type Metadata = { title: string; description: string; @@ -56,3 +66,10 @@ export type Metadata = { dateStart: string; dateEnd: string; }; + +export type JwtPayload = { + exp: number; + walletAddress: string; + permissions: string[]; + refreshable: boolean; +}; From d7fdcea8cbaf8664b97867e2ce23432400cf491a Mon Sep 17 00:00:00 2001 From: Riku Date: Sat, 1 Jul 2023 22:17:40 +0200 Subject: [PATCH 021/135] rework EventTable --- src/components/AuthContext.tsx | 22 ++++-- src/components/AuthStatus.tsx | 3 + src/components/EventTable.tsx | 131 +++++++++++++++++++++++++++++++++ src/pages/HomePage.tsx | 66 ++--------------- 4 files changed, 156 insertions(+), 66 deletions(-) create mode 100644 src/components/EventTable.tsx diff --git a/src/components/AuthContext.tsx b/src/components/AuthContext.tsx index 34e314b..13ace51 100644 --- a/src/components/AuthContext.tsx +++ b/src/components/AuthContext.tsx @@ -118,6 +118,7 @@ export function AuthProvider({ children }: AuthProviderProps) { // try to load from cache first let token = checkStore(); if (token) { + console.debug("Using cached jwt"); setJwt(token); setIsAuthenticated(true); return; @@ -173,17 +174,22 @@ export function AuthProvider({ children }: AuthProviderProps) { React.useEffect(() => { let mounted = true; - console.log("Updating auth"); - - if (isAuto && isAvailable) { - if (account) { - try { - login(); - } catch (err) { - console.debug(err); + const load = async () => { + try { + // FIX: setting states without checking for mounted in login + await login(); + } catch (err) { + console.debug(err); + if (mounted) { setIsAuthenticated(false); setJwt(undefined); } + } + }; + + if (isAuto && isAvailable) { + if (account) { + load(); } else { setIsAuthenticated(false); setJwt(undefined); diff --git a/src/components/AuthStatus.tsx b/src/components/AuthStatus.tsx index 24fb0a4..d8b8179 100644 --- a/src/components/AuthStatus.tsx +++ b/src/components/AuthStatus.tsx @@ -30,8 +30,10 @@ import ManageAccountsIcon from "@mui/icons-material/ManageAccounts"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; import { useAuth } from "components/AuthContext"; +import { useWeb3 } from "connectors/context"; function AuthStatus() { + const { isActive } = useWeb3(); const { isAuthenticated, isAuto, login, logout, toggleAuto } = useAuth(); const setActiveDialog = useSetAtom(activeDialogAtom); const anchorRef = React.useRef(null); @@ -175,6 +177,7 @@ function AuthStatus() { ) : ( { setOpen(false); handleLogin(); diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx new file mode 100644 index 0000000..5c0c71b --- /dev/null +++ b/src/components/EventTable.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { useSetAtom } from "jotai"; + +import EventAvailableIcon from "@mui/icons-material/EventAvailable"; +import FileCopyIcon from "@mui/icons-material/FileCopy"; +import GroupAddIcon from "@mui/icons-material/GroupAdd"; +import InfoIcon from "@mui/icons-material/Info"; +import { GridActionsCellItem, GridColDef } from "@mui/x-data-grid"; + +import { useWeb3 } from "connectors/context"; +import { DialogIdentifier } from "types"; +import DataTable from "components/DataTable"; +import { activeDialogAtom } from "states/atoms"; + +export type EventTableRow = { + id: number; + title: string; + address: string; + count: number; +}; + +export type EventTableProps = { + rows: EventTableRow[]; + isOwner?: boolean; + isAttendee?: boolean; +}; + +export function EventTable(props: EventTableProps) { + const { rows, isOwner, isAttendee } = props; + const { isActive } = useWeb3(); + const setActiveDialog = useSetAtom(activeDialogAtom); + const navigate = useNavigate(); + + const handleAdd = React.useCallback( + (id: number) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_ADD, + data: { eventId: id }, + }); + }, + [setActiveDialog] + ); + + const handleJoin = React.useCallback( + (id: number, title: string) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_JOIN, + data: { eventId: id, title: title }, + }); + }, + [setActiveDialog] + ); + + const handleClaim = React.useCallback( + (id: number) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_CLAIM, + data: { eventId: id }, + }); + }, + [setActiveDialog] + ); + + const columns = React.useMemo[]>( + () => [ + { + field: "id", + headerName: "ID", + type: "number", + width: 45, + minWidth: 45, + }, + { field: "title", headerName: "Title", type: "string", flex: 1 }, + { + field: "address", + headerName: "Owner Address", + type: "string", + width: 180, + }, + { field: "count", headerName: "Slots", type: "number", width: 60 }, + { + field: "actions", + type: "actions", + width: 45, + getActions: (params) => [ + } + label="Add Participant" + onClick={() => handleAdd(params.row.id)} + disabled={!(isActive && isOwner)} + showInMenu + />, + } + label="Join Event" + onClick={() => handleJoin(params.row.id, params.row.title)} + disabled={!isActive} + showInMenu + />, + } + label="Claim NFT" + onClick={() => handleClaim(params.row.id)} + disabled={!(isActive && isAttendee)} + showInMenu + />, + } + label="Show Details" + onClick={() => navigate(`/event/${params.row.id}`)} + showInMenu + />, + ], + }, + ], + [ + isActive, + isOwner, + isAttendee, + handleAdd, + handleClaim, + handleJoin, + navigate, + ] + ); + + return ; +} + +export default EventTable; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index da205a5..3ba10b2 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,30 +1,25 @@ import React from "react"; -import { useNavigate } from "react-router-dom"; import axios from "axios"; import { useSnackbar } from "notistack"; import { useAtom } from "jotai"; import Box from "@mui/material/Box"; -import Stack from "@mui/material/Stack"; import Button from "@mui/material/Button"; -import Chip from "@mui/material/Chip"; import Paper from "@mui/material/Paper"; import Typography from "@mui/material/Typography"; -import type { GridColDef } from "@mui/x-data-grid"; import API from "apis"; import { useWeb3 } from "connectors/context"; import { DialogIdentifier, Event, NetworkIdentifier } from "types"; import Loader from "components/Loader"; -import DataTable from "components/DataTable"; import { activeDialogAtom } from "states/atoms"; +import EventTable, { type EventTableRow } from "components/EventTable"; function HomePage() { - const { account, isActive, networkId } = useWeb3(); - const [data, setData] = React.useState(undefined); + const { isActive, networkId } = useWeb3(); + const [data, setData] = React.useState(); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); - const navigate = useNavigate(); React.useEffect(() => { let mounted = true; @@ -32,8 +27,8 @@ function HomePage() { const load = async () => { try { const events = await API.getEvents({ - networkId: networkId?? NetworkIdentifier.UNKNOWN, - limit: 50, + networkId: networkId ?? NetworkIdentifier.UNKNOWN, + limit: 100, }); if (mounted) { @@ -64,54 +59,9 @@ function HomePage() { return () => { mounted = false; }; - }, [activeDialog, isActive]); + }, [activeDialog, isActive, networkId]); - const handleJoin = async (id: number, title: string) => { - setActiveDialog({ - type: DialogIdentifier.DIALOG_JOIN, - data: { eventId: id, title: title }, - }); - }; - - const columns: GridColDef[] = React.useMemo( - () => [ - { field: "id", headerName: "ID", width: 45, minWidth: 45 }, - { field: "title", headerName: "Title", flex: 1 }, - { field: "address", headerName: "Owner Address", width: 180 }, - { field: "count", headerName: "Slots", width: 60 }, - { - field: "actions", - headerName: "Actions", - sortable: false, - filterable: false, - width: 130, - renderCell: (params) => { - return ( - - handleJoin(params.row.id, params.row.title)} - disabled={!isActive} - /> - navigate(`/event/${params.row.id}`)} - /> - - ); - }, - }, - ], - [account, isActive] - ); - - const rows = React.useMemo(() => { + const rows = React.useMemo(() => { if (data) { return data.map((event) => ({ id: event.id, @@ -145,7 +95,7 @@ function HomePage() { Public Events - {data ? : } + {data ? : } diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index 215d71a..5f221eb 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -39,6 +39,7 @@ import API from "apis"; import { useWeb3 } from "connectors/context"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; +import { useAuth } from "components/AuthContext"; const schemaCommon = object({ title: string() @@ -118,6 +119,7 @@ type MintDialogProps = { function MintDialog(props: MintDialogProps) { const { account, networkId } = useWeb3(); + const { isAuthenticated, jwt } = useAuth(); const [open, setOpen] = React.useState(false); const [loading, setLoading] = React.useState(false); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); @@ -154,10 +156,9 @@ function MintDialog(props: MintDialogProps) { const onSubmit: SubmitHandler = async (values) => { setLoading(true); try { - if (account && networkId) { - const result = await API.mint({ + if (account && networkId && jwt) { + const result = await API.event.create(jwt, { networkId: networkId, - walletAddress: account, tokenCount: values.tokenCount, title: values.title, description: values.description, @@ -363,7 +364,7 @@ function MintDialog(props: MintDialogProps) { color="primary" onClick={handleSubmit(onSubmit)} startIcon={loading && } - disabled={loading || !Boolean(account) || !isValid} + disabled={loading || !Boolean(account) || !isValid || !isAuthenticated} > Create diff --git a/src/pages/EventInfoPage.tsx b/src/pages/EventInfoPage.tsx index 734d4e3..c8e2722 100644 --- a/src/pages/EventInfoPage.tsx +++ b/src/pages/EventInfoPage.tsx @@ -16,8 +16,10 @@ import API from "apis"; import type { Event, Metadata } from "types"; import Loader from "components/Loader"; import DataTable from "components/DataTable"; +import { useAuth } from "components/AuthContext"; function EventInfoPage() { + const { jwt } = useAuth(); const [data, setData] = React.useState(); const [metadata, setMetadata] = React.useState(); const { enqueueSnackbar } = useSnackbar(); @@ -30,11 +32,7 @@ function EventInfoPage() { const load = async () => { try { - const event = await API.getEvent({ - id: parseInt(id!), - includeAttendees: true, - }); - + const event = await API.event.getInfo(id!, jwt); if (mounted) { setData(event ? event : null); } diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 3ba10b2..ad218ea 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -26,7 +26,7 @@ function HomePage() { const load = async () => { try { - const events = await API.getEvents({ + const events = await API.events.getPublic({ networkId: networkId ?? NetworkIdentifier.UNKNOWN, limit: 100, }); From a7fbb5f5d9d14e89f5816aaf188988455273785a Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 3 Jul 2023 12:17:57 +0200 Subject: [PATCH 023/135] add disabled to mint dialog checkbox --- src/components/MintDialog.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index 5f221eb..bfd8c02 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -342,7 +342,7 @@ function MintDialog(props: MintDialogProps) { color={ !!fieldState.error ? "error" - : value + : value && !loading ? "inherit" : "text.secondary" } @@ -350,6 +350,7 @@ function MintDialog(props: MintDialogProps) { Allow any platform user to join the event (public) } + disabled={loading} /> )} @@ -364,7 +365,9 @@ function MintDialog(props: MintDialogProps) { color="primary" onClick={handleSubmit(onSubmit)} startIcon={loading && } - disabled={loading || !Boolean(account) || !isValid || !isAuthenticated} + disabled={ + loading || !Boolean(account) || !isValid || !isAuthenticated + } > Create From d41028bab7ea59173fb679f15b26970250d860f3 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 3 Jul 2023 13:43:47 +0200 Subject: [PATCH 024/135] add content wrapper --- src/components/ContentWrapper.tsx | 68 +++++++++++++++++++++++++++++++ src/components/HelpButton.tsx | 53 ++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/components/ContentWrapper.tsx create mode 100644 src/components/HelpButton.tsx diff --git a/src/components/ContentWrapper.tsx b/src/components/ContentWrapper.tsx new file mode 100644 index 0000000..0b981fb --- /dev/null +++ b/src/components/ContentWrapper.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; + +import Box from "@mui/material/Box"; +import Paper from "@mui/material/Paper"; +import Typography from "@mui/material/Typography"; + +import HelpButton from "components/HelpButton"; +import Loader from "components/Loader"; + +export type ContentWrapperProps = { + children?: ReactNode; + title?: string; + tooltip?: ReactNode; + secondary?: ReactNode; + isLoading?: boolean; + isAuthenticated?: boolean; +}; + +export function ContentWrapper(props: ContentWrapperProps) { + const { children, title, tooltip, secondary, isLoading, isAuthenticated } = + props; + return ( + + + + {secondary} + {tooltip && ( + + )} + + {title && ( + + + {title} + + + )} + {isAuthenticated ? ( + isLoading ? ( + + ) : ( + children + ) + ) : ( + + + Not Authorized! + + + )} + + + ); +} + +export default ContentWrapper; diff --git a/src/components/HelpButton.tsx b/src/components/HelpButton.tsx new file mode 100644 index 0000000..4f464ea --- /dev/null +++ b/src/components/HelpButton.tsx @@ -0,0 +1,53 @@ +import React from "react"; + +import { styled } from "@mui/material/styles"; +import { SxProps, Theme } from "@mui/system"; +import Fade from "@mui/material/Fade"; +import IconButton from "@mui/material/IconButton"; +import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; + +const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + maxWidth: 320, + }, + "& .MuiTypography-subtitle1": { + fontWeight: 600, + paddingTop: "0.25rem", + paddingBottom: "0.25rem", + }, + "& .MuiTypography-subtitle2": { + fontWeight: 600, + paddingBottom: "0.25rem", + }, + "& .MuiTypography-body2": { + paddingBottom: "0.25rem", + }, +})); + +export type HelpButtonProps = { + sx?: SxProps; + content: string | React.ReactNode; +}; + +export function HelpButton(props: HelpButtonProps) { + const { sx, content } = props; + + return ( + + + + + + ); +} + +export default HelpButton; From cf04923ff20bd4774f12ee360d3983c9107c1170 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 3 Jul 2023 16:06:03 +0200 Subject: [PATCH 025/135] prepare for api 2.0 - new add dialog - new claim dialog - rework join dialog - new organizer page - new attendee page - rework api --- LICENSE | 21 --- package.json | 21 +-- src/apis/auth.ts | 24 +-- src/apis/event.ts | 67 +++++-- src/apis/events.ts | 12 +- src/apis/index.ts | 2 + src/apis/offers.ts | 29 +++ src/apis/user.ts | 12 +- src/components/AddDialog.tsx | 297 +++++++++++++++++++++++++++++++ src/components/AuthContext.tsx | 5 +- src/components/ClaimDialog.tsx | 153 ++++++++++++++++ src/components/EventTable.tsx | 60 +++++-- src/components/Header.tsx | 13 +- src/components/JoinDialog.tsx | 46 ++--- src/components/MintDialog.tsx | 17 +- src/components/ProfileDialog.tsx | 46 ++--- src/config.ts | 2 + src/connectors/gem.ts | 6 +- src/layouts/MainLayout.tsx | 14 +- src/pages/AttendeePage.tsx | 95 ++++++++++ src/pages/EventInfoPage.tsx | 6 +- src/pages/EventsPage.tsx | 21 --- src/pages/HomePage.tsx | 75 ++++---- src/pages/OffersPage.tsx | 21 --- src/pages/OrganizerPage.tsx | 111 ++++++++++++ src/routes/DefaultRoutes.tsx | 4 +- src/routes/MainRoutes.tsx | 16 +- src/types.ts | 18 +- 28 files changed, 947 insertions(+), 267 deletions(-) delete mode 100644 LICENSE create mode 100644 src/apis/offers.ts create mode 100644 src/components/AddDialog.tsx create mode 100644 src/components/ClaimDialog.tsx create mode 100644 src/pages/AttendeePage.tsx delete mode 100644 src/pages/EventsPage.tsx delete mode 100644 src/pages/OffersPage.tsx create mode 100644 src/pages/OrganizerPage.tsx diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d928cbf..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 XRPL Bounties - -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: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -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. diff --git a/package.json b/package.json index 8f52907..475f5be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "poap-app", - "version": "0.1.0", + "version": "0.3.0", "description": "Dashboard for the Proof of Attendance Protocol", "author": "Riku Block", "private": true, @@ -16,16 +16,8 @@ "@mui/material": "^5.13.2", "@mui/x-data-grid": "^6.7.0", "@mui/x-date-pickers": "^6.8.0", - "@testing-library/jest-dom": "^5.14.1", - "@testing-library/react": "^13.0.0", - "@testing-library/user-event": "^13.2.1", - "@types/jest": "^27.0.1", - "@types/node": "^16.7.13", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", "axios": "^1.4.0", "date-fns": "^2.30.0", - "fuse.js": "^6.6.2", "jotai": "^2.1.0", "notistack": "^3.0.1", "react": "^18.2.0", @@ -34,7 +26,7 @@ "react-jwt": "^1.2.0", "react-router-dom": "^6.11.2", "react-scripts": "5.0.1", - "typescript": "^5.1.3", + "react-window": "^1.8.9", "web-vitals": "^2.1.0", "xrpl": "^2.7.0", "xumm": "^1.5.4", @@ -64,12 +56,21 @@ ] }, "devDependencies": { + "@testing-library/jest-dom": "^5.14.1", + "@testing-library/react": "^13.0.0", + "@testing-library/user-event": "^13.2.1", + "@types/jest": "^27.0.1", + "@types/node": "^16.7.13", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@types/react-window": "^1.8.5", "dotenv-webpack": "^8.0.1", "esbuild-loader": "^3.0.1", "html-webpack-plugin": "^5.5.3", "node-polyfill-webpack-plugin": "^2.0.1", "ts-loader": "^9.4.3", "tsconfig-paths-webpack-plugin": "^4.0.1", + "typescript": "^5.1.3", "webpack": "^5.87.0", "webpack-cli": "^5.1.4", "webpack-dev-server": "^4.15.1" diff --git a/src/apis/auth.ts b/src/apis/auth.ts index c78c9f1..ac605b6 100644 --- a/src/apis/auth.ts +++ b/src/apis/auth.ts @@ -12,11 +12,7 @@ export const heartbeat = async (): Promise => { } ); - if (response.status === 200) { - return response.data.result as boolean; - } - - throw new Error(response.status.toString()); + return response.data.result as boolean; }; export type nonceData = { @@ -33,11 +29,7 @@ export const nonce = async (data: nonceData): Promise => { } ); - if (response.status === 200) { - return response.data.result as string; - } - - throw new Error(response.status.toString()); + return response.data.result as string; }; export type loginData = { @@ -57,11 +49,7 @@ export const login = async (data: loginData): Promise => { } ); - if (response.status === 200) { - return response.data.result as string; - } - - throw new Error(response.status.toString()); + return response.data.result as string; }; export type refreshResult = string | null; @@ -79,9 +67,5 @@ export const refresh = async (jwt: string): Promise => { } ); - if (response.status === 200) { - return response.data.result as refreshResult; - } - - throw new Error(`${response.status} ${response.data.error}`); // TODO want the actual message + return response.data.result as refreshResult; }; diff --git a/src/apis/event.ts b/src/apis/event.ts index e34e3b7..e3669ac 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -37,21 +37,34 @@ export const create = async ( } ); - if (response.status === 200) { - return response.data.result as createResult; - } + return response.data.result as createResult; +}; - throw new Error(response.status.toString()); +export type joinData = { + eventId: number; +}; + +export const join = async (jwt: string, data: joinData): Promise => { + const response = await axios.post( + new URL("/event/join", config.apiURL).toString(), + data, + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: 10000, + } + ); + + return response.data.result as Offer; }; export type claimData = { eventId: number; }; -export const claim = async ( - jwt: string, - data: claimData -): Promise => { +export const claim = async (jwt: string, data: claimData): Promise => { const response = await axios.post( new URL("/event/claim", config.apiURL).toString(), data, @@ -60,15 +73,37 @@ export const claim = async ( Authorization: `Bearer ${jwt}`, }, responseType: "json", - timeout: 60000, + timeout: 10000, } ); - if (response.status === 200) { - return response.data.result as Offer; - } + return response.data.result as Offer; +}; - throw new Error(response.status.toString()); +export type inviteData = { + eventId: number; + attendeeWalletAddresses: string[]; +}; + +export type inviteResult = boolean; + +export const invite = async ( + jwt: string, + data: inviteData +): Promise => { + const response = await axios.post( + new URL("/event/invite", config.apiURL).toString(), + data, + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: 600000, + } + ); + + return response.data.result as inviteResult; }; export type getInfoResult = Event | undefined; @@ -92,9 +127,5 @@ export const getInfo = async ( } ); - if (response.status === 200) { - return response.data.result as getInfoResult; - } - - throw new Error(response.status.toString()); + return response.data.result as getInfoResult; }; diff --git a/src/apis/events.ts b/src/apis/events.ts index 40a0486..0390163 100644 --- a/src/apis/events.ts +++ b/src/apis/events.ts @@ -19,11 +19,7 @@ export const getPublic = async (params: getPublicParams): Promise => { } ); - if (response.status === 200) { - return response.data.result as Event[]; - } - - throw new Error(response.status.toString()); + return response.data.result as Event[]; }; export type getOwnedParams = { @@ -48,9 +44,5 @@ export const getOwned = async ( } ); - if (response.status === 200) { - return response.data.result as Event[]; - } - - throw new Error(response.status.toString()); + return response.data.result as Event[]; }; diff --git a/src/apis/index.ts b/src/apis/index.ts index 60c877e..4ecae83 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,12 +1,14 @@ import * as auth from "./auth"; import * as event from "./event"; import * as events from "./events"; +import * as offers from "./offers"; import * as user from "./user"; export const API = { auth, event, events, + offers, user, }; diff --git a/src/apis/offers.ts b/src/apis/offers.ts new file mode 100644 index 0000000..3fe7851 --- /dev/null +++ b/src/apis/offers.ts @@ -0,0 +1,29 @@ +import axios from "axios"; +import config from "config"; + +import type { Offer } from "types"; +import { NetworkIdentifier } from "types"; + +export type getAllParams = { + networkId: NetworkIdentifier; + limit?: number; +}; + +export const getAll = async ( + jwt: string, + params: getAllParams +): Promise => { + const response = await axios.get( + new URL("/offers", config.apiURL).toString(), + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + params: params, + } + ); + + return response.data.result as Offer[]; +}; diff --git a/src/apis/user.ts b/src/apis/user.ts index db07c98..7c3ebaa 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -23,11 +23,7 @@ export const getInfo = async ( } ); - if (response.status === 200) { - return response.data.result as User; - } - - throw new Error(response.status.toString()); + return response.data.result as User; }; export type updateData = { @@ -52,9 +48,5 @@ export const update = async ( } ); - if (response.status === 200) { - return response.data.result as boolean; - } - - throw new Error(response.status.toString()); + return response.data.result as boolean; }; diff --git a/src/components/AddDialog.tsx b/src/components/AddDialog.tsx new file mode 100644 index 0000000..4d7204b --- /dev/null +++ b/src/components/AddDialog.tsx @@ -0,0 +1,297 @@ +import React from "react"; +import axios from "axios"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import { isValidClassicAddress } from "xrpl"; + +import { + useForm, + SubmitHandler, + Controller, + DefaultValues, + ControllerFieldState, +} from "react-hook-form"; +import { array, object, string, TypeOf } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import TextField from "@mui/material/TextField"; +import Stack from "@mui/material/Stack"; +import CloseIcon from "@mui/icons-material/Close"; +import CircularProgress from "@mui/material/CircularProgress"; +import Autocomplete, { createFilterOptions } from "@mui/material/Autocomplete"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; +import { useAuth } from "components/AuthContext"; + +const schema = object({ + addresses: array( + object({ + address: string() + .nonempty("Address is required") + .max(256, "Address must be less than 64 characters") + .trim() + .refine( + (value) => isValidClassicAddress(value), + "Must be a valid classic XRP address" + ), + displayValue: string().optional(), + }) + ) + .min(1, "Must provide at least 1 wallet address") + .refine( + (items) => { + const addresses = items.map((x) => x.address); + return new Set(addresses).size === items.length; + }, + { + message: "Must be a list of unique addresses", + } + ), +}); + +type AddFormValues = TypeOf; + +type ArrayElementType = T extends (infer E)[] ? E : T; + +const filter = createFilterOptions< + ArrayElementType +>({ + ignoreCase: false, +}); + +const defaultValues: DefaultValues = { + addresses: [], +}; + +type AddDialogData = Record; + +function AddDialog() { + const { account } = useWeb3(); + const { isAuthenticated, jwt } = useAuth(); + const [open, setOpen] = React.useState(false); + const [data, setData] = React.useState(); + const [loading, setLoading] = React.useState(false); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + const { + control, + formState: { isValid }, + reset, + handleSubmit, + } = useForm({ + mode: "all", + defaultValues: defaultValues, + resolver: zodResolver(schema), + }); + + // no default options for now + const options: AddFormValues["addresses"] = []; + + React.useEffect(() => { + setOpen(activeDialog.type === DialogIdentifier.DIALOG_ADD); + setData(activeDialog.data); + }, [activeDialog]); + + const handleClose = (event: {}, reason?: string) => { + if (reason === "backdropClick") { + return; + } + setActiveDialog({}); + }; + + const handleCancel = (event: React.MouseEvent) => { + reset(); + setActiveDialog({}); + }; + + const onSubmit: SubmitHandler = async (values) => { + setLoading(true); + try { + if (account && data?.eventId && jwt) { + const addresses: string[] = values.addresses.map( + (item) => item.address + ); + await API.event.invite(jwt, { + eventId: data.eventId, + attendeeWalletAddresses: addresses, + }); + enqueueSnackbar( + `Invite successful: Added ${addresses.length} participant(s)`, + { + variant: "success", + } + ); + reset(); + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Invite failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Invite failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + setActiveDialog({}); + } + }; + + const getHelperText = ( + fieldState: ControllerFieldState + ): string | undefined => { + if (Array.isArray(fieldState.error) && fieldState.error.length > 0) { + return fieldState.error[0].address.message as string; + } else { + return fieldState.error?.message; + } + }; + + return ( + + + Add new Participants to Event #{data?.eventId} + + theme.palette.grey[500], + }} + size="small" + onClick={handleClose} + disabled={loading} + > + + + + + Add any platform user to this event. Please note, that users must + exist on the platform and must have an activated, + valid XRP address on the selected network. + + + ( + { + onChange( + values.map((v) => { + if (typeof v === "string") { + return { address: v }; + } else { + return v; + } + }) + ); + }} + filterOptions={(options, state) => { + const filtered = filter(options, state); + + // Suggest the creation of a new value + const { inputValue } = state; + const value = inputValue.trim(); + const isExisting = options.some( + (option) => value === option.address + ); + if (value !== "" && !isExisting) { + filtered.push({ + address: value, + displayValue: `Add "${value}"`, + }); + } + + return filtered; + }} + selectOnFocus + clearOnBlur + handleHomeEndKeys + options={options} + getOptionLabel={(option) => { + if (typeof option === "string") { + return option; + } else { + return option.address; + } + }} + renderOption={(props, option) => ( +
  • {option.displayValue ?? option.address}
  • + )} + freeSolo + multiple + renderInput={(params) => ( + + )} + /> + )} + >
    +
    +
    + + + + + +
    + ); +} + +export default AddDialog; diff --git a/src/components/AuthContext.tsx b/src/components/AuthContext.tsx index 13ace51..6e967de 100644 --- a/src/components/AuthContext.tsx +++ b/src/components/AuthContext.tsx @@ -74,7 +74,7 @@ export type AuthProviderProps = { export function AuthProvider({ children }: AuthProviderProps) { const { connector, provider, account, networkId } = useWeb3(); const { isAuto, tokens, addToken, removeToken, toggleAuto } = useAuthStore(); - const [isAvailable, setIsAvailable] = React.useState(false); + const [isAvailable, setIsAvailable] = React.useState(true); const [isAuthenticated, setIsAuthenticated] = React.useState(false); const [jwt, setJwt] = React.useState(); @@ -153,7 +153,8 @@ export function AuthProvider({ children }: AuthProviderProps) { if (mounted) { setIsAvailable(true); } - } catch (error) { + } catch (err) { + console.log(err); if (mounted) { setIsAvailable(false); } diff --git a/src/components/ClaimDialog.tsx b/src/components/ClaimDialog.tsx new file mode 100644 index 0000000..eaf2d65 --- /dev/null +++ b/src/components/ClaimDialog.tsx @@ -0,0 +1,153 @@ +import React from "react"; +import axios from "axios"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; + +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import CloseIcon from "@mui/icons-material/Close"; +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; +import { useAuth } from "components/AuthContext"; + +type ClaimDialogData = Record; + +function ClaimDialog() { + const { provider, account } = useWeb3(); + const { isAuthenticated, jwt } = useAuth(); + const [open, setOpen] = React.useState(false); + const [data, setData] = React.useState(); + const [loading, setLoading] = React.useState(false); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + React.useEffect(() => { + setOpen(activeDialog.type === DialogIdentifier.DIALOG_CLAIM); + setData(activeDialog.data); + }, [activeDialog]); + + const handleClose = (event: {}, reason?: string) => { + if (reason === "backdropClick") { + return; + } + setActiveDialog({}); + }; + + const handleCancel = (event: React.MouseEvent) => { + setActiveDialog({}); + }; + + const handleConfirm = async (event: React.MouseEvent) => { + setLoading(true); + try { + if (provider && account && data?.eventId && jwt) { + const offer = await API.event.claim(jwt, { + eventId: data.eventId, + }); + console.debug("ClaimResult", offer); + + if (!offer.claimed) { + enqueueSnackbar( + "Creating NFT claim request (confirm the transaction in your wallet)", + { + variant: "warning", + autoHideDuration: 30000, + } + ); + const success = await provider.acceptOffer(offer.offerIndex); + + if (success) { + enqueueSnackbar("Claim successful", { + variant: "success", + }); + } else { + enqueueSnackbar(`Claim failed: Unable to claim NFT`, { + variant: "error", + }); + } + } else { + enqueueSnackbar(`Claim successful: Already claimed NFT`, { + variant: "success", + }); + } + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Claim failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Claim failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + } + + setActiveDialog({}); + }; + + return ( + + + Claim NFT for Event #{data?.eventId} + + theme.palette.grey[500], + }} + size="small" + onClick={handleClose} + disabled={loading} + > + + + + + Would you like to claim your NFT now? + + + + + + + + + ); +} + +export default ClaimDialog; diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index 5c0c71b..ef69391 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -6,7 +6,12 @@ import EventAvailableIcon from "@mui/icons-material/EventAvailable"; import FileCopyIcon from "@mui/icons-material/FileCopy"; import GroupAddIcon from "@mui/icons-material/GroupAdd"; import InfoIcon from "@mui/icons-material/Info"; -import { GridActionsCellItem, GridColDef } from "@mui/x-data-grid"; +import { + GridActionsCellItem, + GridColDef, + GridTreeNodeWithRender, + GridValueGetterParams, +} from "@mui/x-data-grid"; import { useWeb3 } from "connectors/context"; import { DialogIdentifier } from "types"; @@ -16,8 +21,12 @@ import { activeDialogAtom } from "states/atoms"; export type EventTableRow = { id: number; title: string; - address: string; - count: number; + dateStart: Date; + dateEnd: Date; + slotsTaken?: number; + slotsTotal: number; + claimed?: boolean; + offerIndex?: string; }; export type EventTableProps = { @@ -26,6 +35,12 @@ export type EventTableProps = { isAttendee?: boolean; }; +type GetterParamsType = GridValueGetterParams< + EventTableRow, + any, + GridTreeNodeWithRender +>; + export function EventTable(props: EventTableProps) { const { rows, isOwner, isAttendee } = props; const { isActive } = useWeb3(); @@ -73,16 +88,41 @@ export function EventTable(props: EventTableProps) { }, { field: "title", headerName: "Title", type: "string", flex: 1 }, { - field: "address", - headerName: "Owner Address", - type: "string", - width: 180, + field: "dateStart", + headerName: "Start", + type: "date", + width: 100, }, - { field: "count", headerName: "Slots", type: "number", width: 60 }, + { field: "dateEnd", headerName: "End", type: "date", width: 100 }, + ...(isAttendee + ? [ + { + field: "claimed", + headerName: "Claimed", + type: "boolean", + width: 80, + }, + ] + : [ + { + field: "slots", + headerName: "Slots", + type: "number", + width: 60, + valueGetter: ({ row }: GetterParamsType) => { + if (row.slotsTaken !== undefined) { + return `${row.slotsTaken}/${row.slotsTotal}`; + } else { + return row.slotsTotal; + } + }, + }, + ]), { field: "actions", type: "actions", width: 45, + minWidth: 45, getActions: (params) => [ } @@ -95,14 +135,14 @@ export function EventTable(props: EventTableProps) { icon={} label="Join Event" onClick={() => handleJoin(params.row.id, params.row.title)} - disabled={!isActive} + disabled={!(isActive && !isAttendee)} showInMenu />, } label="Claim NFT" onClick={() => handleClaim(params.row.id)} - disabled={!(isActive && isAttendee)} + disabled={!(isActive && isAttendee && !params.row.claimed)} showInMenu />, ( ({ theme }) => ({ @@ -43,15 +45,18 @@ const StyledList = styled(MuiList)<{ component?: React.ElementType }>( function Header() { const { isActive } = useWeb3(); + const { isAuthenticated } = useAuth(); const entries: Array<[string, string, boolean]> = React.useMemo( () => [ ["Home", "/", false], - ["Events", "/events", !isActive], - ["Signups", "/offers", !isActive], - ["Debug", "/debug", false], + ["Organizer", "/organizer", !(isActive && isAuthenticated)], + ["Attendee", "/attendee", !(isActive && isAuthenticated)], + ...(config.debug + ? ([["Debug", "/debug", false]] as Array<[string, string, boolean]>) + : []), ], - [isActive] + [isActive, isAuthenticated] ); return ( diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index d218f1b..938f73f 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -1,6 +1,6 @@ import React from "react"; +import axios from "axios"; import { useAtom } from "jotai"; -import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; import Button from "@mui/material/Button"; @@ -23,15 +23,11 @@ import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; import { useAuth } from "components/AuthContext"; -type JoinDialogProps = { - children?: ReactNode; -}; - type JoinDialogData = Record; -function JoinDialog(props: JoinDialogProps) { +function JoinDialog() { const { provider, account } = useWeb3(); - const { isAuthenticated, jwt} = useAuth(); + const { isAuthenticated, jwt } = useAuth(); const [open, setOpen] = React.useState(false); const [checked, setChecked] = React.useState(true); const [data, setData] = React.useState(); @@ -59,49 +55,53 @@ function JoinDialog(props: JoinDialogProps) { setLoading(true); try { if (provider && account && data?.eventId && jwt) { - const result = await API.event.claim(jwt, { + const offer = await API.event.join(jwt, { eventId: data.eventId, }); - console.debug("ClaimResult", result); + console.debug("JoinResult", offer); - // TODO give user better feedback when already joined an event - if (!result.claimed) { + if (!offer.claimed) { if (checked) { enqueueSnackbar( - "Creating NFT claim request: Confirm the transaction in your wallet", + "Creating NFT claim request (confirm the transaction in your wallet)", { variant: "warning", autoHideDuration: 30000, } ); - const success = await provider.acceptOffer(result.offerIndex); + const success = await provider.acceptOffer(offer.offerIndex); if (success) { - enqueueSnackbar(`Sign-up successful: Event #${data.eventId}`, { + enqueueSnackbar("Claim successful", { variant: "success", }); } else { - enqueueSnackbar(`Sign-up failed: Unable to claim NFT`, { + enqueueSnackbar(`Claim failed: Unable to claim NFT`, { variant: "error", }); } } } else { - enqueueSnackbar(`Sign-up successful: Already claimed NFT`, { + enqueueSnackbar(`Claim successful: Already claimed NFT`, { variant: "success", }); } } - } catch (error) { - console.debug(error); - enqueueSnackbar(`Sign-up failed: ${(error as Error).message}`, { - variant: "error", - }); + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { + variant: "error", + }); + } } finally { setLoading(false); + setActiveDialog({}); } - - setActiveDialog({}); }; const handleChange = (event: React.ChangeEvent) => { diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index bfd8c02..cb82f91 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -1,4 +1,5 @@ import React from "react"; +import axios from "axios"; import { useAtom } from "jotai"; import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; @@ -174,11 +175,17 @@ function MintDialog(props: MintDialogProps) { }); reset(); } - } catch (error) { - console.debug(error); - enqueueSnackbar(`Mint failed: ${(error as Error).message}`, { - variant: "error", - }); + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Mint failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Mint failed: ${(err as Error).message}`, { + variant: "error", + }); + } } finally { setLoading(false); setActiveDialog({}); diff --git a/src/components/ProfileDialog.tsx b/src/components/ProfileDialog.tsx index 6ba50be..2e45db1 100644 --- a/src/components/ProfileDialog.tsx +++ b/src/components/ProfileDialog.tsx @@ -1,7 +1,6 @@ import React from "react"; import axios from "axios"; import { useAtom } from "jotai"; -import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; import { useForm, SubmitHandler } from "react-hook-form"; import { object, string, literal, TypeOf } from "zod"; @@ -42,11 +41,7 @@ const defaultValues: ProfileFormValues = { email: "", }; -type ProfileDialogProps = { - children?: ReactNode; -}; - -function ProfileDialog(props: ProfileDialogProps) { +function ProfileDialog() { const { account } = useWeb3(); const { isAuthenticated, jwt } = useAuth(); const [open, setOpen] = React.useState(false); @@ -71,16 +66,22 @@ function ProfileDialog(props: ProfileDialogProps) { lastName: result?.lastName ?? defaultValues.lastName, email: result?.email ?? defaultValues.email, }; - } catch (error) { - console.debug(error); - if (axios.isAxiosError(error)) { - enqueueSnackbar(`Failed to load profile data: ${error.message}`, { - variant: "error", - }); + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load profile data: ${err.response?.data.error}`, + { + variant: "error", + } + ); } else { - enqueueSnackbar("Failed to load profile data", { - variant: "error", - }); + enqueueSnackbar( + `Failed to load profile data: ${(err as Error).message}`, + { + variant: "error", + } + ); } } } @@ -129,23 +130,22 @@ function ProfileDialog(props: ProfileDialogProps) { variant: "success", }); } - } catch (error) { - console.debug(error); - if (axios.isAxiosError(error)) { - enqueueSnackbar(`Profile update failed: ${error.message}`, { + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Profile update failed: ${err.response?.data.error}`, { variant: "error", }); } else { - enqueueSnackbar("Profile update failed", { + enqueueSnackbar(`Profile update failed: ${(err as Error).message}`, { variant: "error", }); } } finally { setLoading(false); + reset(); + setActiveDialog({}); } - - reset(); - setActiveDialog({}); }; return ( diff --git a/src/config.ts b/src/config.ts index cef52b0..68a6a0d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,6 +13,8 @@ const DEFAULT = { }, }, }, + + debug: false, }; const config = DEFAULT; diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index 8f11b02..58efe09 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -141,10 +141,10 @@ export class GemWallet extends Connector { networkId: this.mapNetworkId(network.result.network), account: address.result.address, }); - } catch (error) { + } catch (err) { cancelActivation(); - this.onError?.(error as Error); - throw error; + this.onError?.(err as Error); + throw err; } } diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 9912170..df45e5f 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -4,12 +4,14 @@ import { useAtomValue } from "jotai"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; +import AddDialog from "components/AddDialog"; +import ClaimDialog from "components/ClaimDialog"; import Header from "components/Header"; -import MintDialog from "components/MintDialog"; import JoinDialog from "components/JoinDialog"; +import MintDialog from "components/MintDialog"; import ProfileDialog from "components/ProfileDialog"; -import { activeDialogAtom } from "states/atoms"; -import { DialogIdentifier } from "types"; function MainLayout(props: any) { const activeDialog = useAtomValue(activeDialogAtom); @@ -28,10 +30,12 @@ function MainLayout(props: any) { > - + + + { - // mounted component every time the dialog is opened to ensure + // mounted component every time the dialog is opened to ensure // the latest values from the database are load activeDialog.type === DialogIdentifier.DIALOG_PROFILE && ( diff --git a/src/pages/AttendeePage.tsx b/src/pages/AttendeePage.tsx new file mode 100644 index 0000000..e39ec60 --- /dev/null +++ b/src/pages/AttendeePage.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import axios from "axios"; +import { useAtomValue } from "jotai"; +import { useSnackbar } from "notistack"; + +import { activeDialogAtom } from "states/atoms"; +import { Offer } from "types"; +import { useAuth } from "components/AuthContext"; +import { useWeb3 } from "connectors/context"; +import API from "apis"; +import ContentWrapper from "components/ContentWrapper"; +import EventTable, { type EventTableRow } from "components/EventTable"; + +function AttendeePage() { + const { isActive, networkId } = useWeb3(); + const { isAuthenticated, jwt } = useAuth(); + const [data, setData] = React.useState(); + const activeDialog = useAtomValue(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (networkId && jwt) { + const offers = await API.offers.getAll(jwt, { + networkId: networkId, + limit: 100, + }); + + if (mounted) { + setData(offers); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Failed to load offers data: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar("Failed to load offers data", { + variant: "error", + }); + } + } + }; + + // only update data, if no dialog is open + if (!activeDialog.type) { + if (isAuthenticated) { + load(); + } else { + setData(undefined); + } + } + + return () => { + mounted = false; + }; + }, [activeDialog, isActive, networkId, isAuthenticated, jwt]); + + const rows = React.useMemo(() => { + if (data) { + return data.map((offer) => ({ + id: offer.token.eventId, + title: offer.token.event.title, + dateStart: new Date(offer.token.event.dateStart), + dateEnd: new Date(offer.token.event.dateEnd), + slotsTaken: offer.token.event.attendees?.length, + slotsTotal: offer.token.event.tokenCount, + claimed: offer.claimed, + offerIndex: offer.offerIndex, + })); + } else { + return []; + } + }, [data]); + + return ( + + ; + + ); +} + +export default AttendeePage; diff --git a/src/pages/EventInfoPage.tsx b/src/pages/EventInfoPage.tsx index c8e2722..9cdf958 100644 --- a/src/pages/EventInfoPage.tsx +++ b/src/pages/EventInfoPage.tsx @@ -42,7 +42,7 @@ function EventInfoPage() { setData(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar(`Failed to load event data: ${err.message}`, { + enqueueSnackbar(`Failed to load event data: ${err.response?.data.error}`, { variant: "error", }); } else { @@ -62,7 +62,7 @@ function EventInfoPage() { return () => { mounted = false; }; - }, [id]); + }, [id, jwt]); React.useEffect(() => { let mounted = true; @@ -83,7 +83,7 @@ function EventInfoPage() { setMetadata(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar(`Failed to load event metadata: ${err.message}`, { + enqueueSnackbar(`Failed to load event metadata: ${err.response?.data.error}`, { variant: "error", }); } else { diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx deleted file mode 100644 index 960983f..0000000 --- a/src/pages/EventsPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; - -import Box from "@mui/material/Box"; -import Paper from "@mui/material/Paper"; -import Typography from "@mui/material/Typography"; - -function EventsPage() { - return ( - - - - - Owned Events: - - - - - ); -} - -export default EventsPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index ad218ea..876d78a 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,26 +1,36 @@ import React from "react"; import axios from "axios"; import { useSnackbar } from "notistack"; -import { useAtom } from "jotai"; +import { useAtomValue } from "jotai"; -import Box from "@mui/material/Box"; -import Button from "@mui/material/Button"; -import Paper from "@mui/material/Paper"; import Typography from "@mui/material/Typography"; import API from "apis"; import { useWeb3 } from "connectors/context"; -import { DialogIdentifier, Event, NetworkIdentifier } from "types"; -import Loader from "components/Loader"; +import { Event, NetworkIdentifier } from "types"; import { activeDialogAtom } from "states/atoms"; import EventTable, { type EventTableRow } from "components/EventTable"; +import ContentWrapper from "components/ContentWrapper"; function HomePage() { const { isActive, networkId } = useWeb3(); const [data, setData] = React.useState(); - const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const activeDialog = useAtomValue(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); + const tooltip = React.useMemo( + () => ( + + Information + + Connect a wallet to continue. This will allow you to join public + events, create new events and claim NFTs from events you're attending. + + + ), + [] + ); + React.useEffect(() => { let mounted = true; @@ -40,7 +50,7 @@ function HomePage() { setData(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar(`Failed to load events data: ${err.message}`, { + enqueueSnackbar(`Failed to load events data: ${err.response?.data.error}`, { variant: "error", }); } else { @@ -66,50 +76,25 @@ function HomePage() { return data.map((event) => ({ id: event.id, title: event.title, - address: event.ownerWalletAddress, - count: event.tokenCount, + dateStart: new Date(event.dateStart), + dateEnd: new Date(event.dateEnd), + slotsTaken: event.attendees?.length, + slotsTotal: event.tokenCount, })); } else { return []; } }, [data]); - const handleClick = (event: React.MouseEvent) => { - setActiveDialog({ type: DialogIdentifier.DIALOG_MINT }); - }; - return ( - - - - - Overview - - {!isActive && ( - - Connect a wallet to continue! (join an existing event or create a - new one) - - )} - - - Public Events - - {data ? : } - - - - - + + + ); } diff --git a/src/pages/OffersPage.tsx b/src/pages/OffersPage.tsx deleted file mode 100644 index 64f4f02..0000000 --- a/src/pages/OffersPage.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; - -import Box from "@mui/material/Box"; -import Paper from "@mui/material/Paper"; -import Typography from "@mui/material/Typography"; - -function OffersPage() { - return ( - - - - - Event Attendances: - - - - - ); -} - -export default OffersPage; diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx new file mode 100644 index 0000000..bab584c --- /dev/null +++ b/src/pages/OrganizerPage.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import axios from "axios"; +import { useSnackbar } from "notistack"; +import { useAtom } from "jotai"; + +import Button from "@mui/material/Button"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { DialogIdentifier, Event } from "types"; +import { activeDialogAtom } from "states/atoms"; +import EventTable, { type EventTableRow } from "components/EventTable"; +import ContentWrapper from "components/ContentWrapper"; +import { useAuth } from "components/AuthContext"; + +function OrganizerPage() { + const { isActive, networkId } = useWeb3(); + const { isAuthenticated, jwt } = useAuth(); + const [data, setData] = React.useState(); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (networkId && jwt) { + const events = await API.events.getOwned(jwt, { + networkId: networkId, + limit: 100, + includeAttendees: true, + }); + + if (mounted) { + setData(events); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Failed to load events data: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar("Failed to load events data", { + variant: "error", + }); + } + } + }; + + // only update data, if no dialog is open + if (!activeDialog.type) { + if (isAuthenticated) { + load(); + } else { + setData(undefined); + } + } + + return () => { + mounted = false; + }; + }, [activeDialog, isActive, networkId, isAuthenticated, jwt]); + + const rows = React.useMemo(() => { + if (data) { + return data.map((event) => ({ + id: event.id, + title: event.title, + dateStart: new Date(event.dateStart), + dateEnd: new Date(event.dateEnd), + slotsTaken: event.attendees?.length, + slotsTotal: event.tokenCount, + })); + } else { + return []; + } + }, [data]); + + const handleClick = (event: React.MouseEvent) => { + setActiveDialog({ type: DialogIdentifier.DIALOG_MINT }); + }; + + return ( + + New + + } + > + + + ); +} + +export default OrganizerPage; diff --git a/src/routes/DefaultRoutes.tsx b/src/routes/DefaultRoutes.tsx index 21534aa..0753a0b 100644 --- a/src/routes/DefaultRoutes.tsx +++ b/src/routes/DefaultRoutes.tsx @@ -1,9 +1,11 @@ import React from "react"; +import type { RouteObject } from "react-router-dom"; + import Loadable from "components/Loadable"; const NotFoundPage = Loadable(React.lazy(() => import("pages/NotFoundPage"))); -const DefaultRoutes = { +const DefaultRoutes: RouteObject = { path: "*", element: , }; diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 72f2c82..9094205 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Navigate } from "react-router-dom"; +import type { RouteObject } from "react-router-dom"; import Loadable from "components/Loadable"; import MainLayout from "layouts/MainLayout"; @@ -7,11 +7,11 @@ import MainLayout from "layouts/MainLayout"; const DebugPage = Loadable(React.lazy(() => import("pages/DebugPage"))); const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); const EventInfoPage = Loadable(React.lazy(() => import("pages/EventInfoPage"))); -const EventsPage = Loadable(React.lazy(() => import("pages/EventsPage"))); +const OrganizerPage = Loadable(React.lazy(() => import("pages/OrganizerPage"))); const HomePage = Loadable(React.lazy(() => import("pages/HomePage"))); -const OffersPage = Loadable(React.lazy(() => import("pages/OffersPage"))); +const AttendeePage = Loadable(React.lazy(() => import("pages/AttendeePage"))); -const MainRoutes = { +const MainRoutes: RouteObject = { path: "/", element: , errorElement: , @@ -25,12 +25,12 @@ const MainRoutes = { element: , }, { - path: "/events", - element: , + path: "/organizer", + element: , }, { - path: "/offers", - element: , + path: "/attendee", + element: , }, { path: "/debug", diff --git a/src/types.ts b/src/types.ts index 6bbd00d..d324ec8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,7 +35,7 @@ export type User = { export type Event = { id: number; - networkId: number; + networkId: NetworkIdentifier; title: string; description: string; location: string; @@ -44,17 +44,27 @@ export type Event = { dateStart: string; dateEnd: string; isManaged: boolean; - ownerWalletAddress: string; + ownerWalletAddress: User["walletAddress"]; owner?: User; attendees?: User[]; }; +export type NFT = { + id: string; + issuerWalletAddress: User["walletAddress"]; + eventId: Event["id"]; + issuer?: User; + event: Event; +}; + export type Offer = { id: number; - ownerWalletAddress: string; - tokenId: number; + ownerWalletAddress: User["walletAddress"]; + tokenId: NFT["id"]; offerIndex: string; claimed: boolean; + owner?: User; + token: NFT; }; export type Metadata = { From 924917fd4edd48ed8c0f65c119bde03ece8065df Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 4 Jul 2023 11:34:29 +0200 Subject: [PATCH 026/135] update readme for API 2.0 --- .env.example | 2 +- README.md | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index bb38196..03ba2c1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,2 @@ REACT_APP_URL_POAP_API="http://localhost:4000/" -REACT_APP_KEY_XUMM_API="57484a14-db63-4c72-a0b7-4e5024a18bd7" +REACT_APP_KEY_XUMM_API="" diff --git a/README.md b/README.md index 590a2d6..ddd5999 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,21 @@ The web app for Proof of Attendance Protocol ## Requirements - Node.js `v18.16.0+` - Yarn `v1.22.19+` -- Running POAP API server (backend), for details see [here](https://github.com/rikublock/POAP-API) +- Running POAP API 2.0 server (backend), for details see [here](https://github.com/rikublock/POAP-API2) ## Getting Started -- install dependencies with `yarn` +- install dependencies with `yarn install` - rename `.env.example` to `.env` (change values as needed) -- run the backend + - `REACT_APP_URL_POAP_API` (backend server URL) + - `REACT_APP_KEY_XUMM_API` (Xumm App API key, needs to match the key configured in the backend) +- ensure the backend service is running - run the app with `yarn start` ## Available Scripts In the project directory, you can run: -### `yarn` +### `yarn install` Install dependencies. From 623684a28cc0e361325beebfc35fadea0906622d Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 4 Jul 2023 11:35:27 +0200 Subject: [PATCH 027/135] limit add participants to at most 20 entries --- src/components/AddDialog.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/AddDialog.tsx b/src/components/AddDialog.tsx index 4d7204b..f160d9e 100644 --- a/src/components/AddDialog.tsx +++ b/src/components/AddDialog.tsx @@ -48,6 +48,7 @@ const schema = object({ }) ) .min(1, "Must provide at least 1 wallet address") + .max(20) .refine( (items) => { const addresses = items.map((x) => x.address); From b7eace924eb2e5cf71cf5568ed2280051a7a208c Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 4 Jul 2023 12:06:01 +0200 Subject: [PATCH 028/135] add permissions state to auth context --- src/components/AuthContext.tsx | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/components/AuthContext.tsx b/src/components/AuthContext.tsx index 6e967de..55a62b1 100644 --- a/src/components/AuthContext.tsx +++ b/src/components/AuthContext.tsx @@ -1,12 +1,12 @@ import type { ReactNode } from "react"; import React from "react"; -import { isExpired } from "react-jwt"; +import { decodeToken, isExpired } from "react-jwt"; import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import API from "apis"; import { useWeb3 } from "connectors/context"; -import { ConnectorType, WalletType } from "types"; +import { ConnectorType, JwtPayload, WalletType } from "types"; import { XummWalletProvider } from "connectors/xumm"; import { GemWalletProvider } from "connectors/gem"; @@ -58,6 +58,7 @@ export type AuthContextType = { isAuthenticated: boolean; isAuto: boolean; jwt?: string; + permissions: string[]; login: () => Promise; logout: () => void; toggleAuto: () => void; @@ -77,6 +78,7 @@ export function AuthProvider({ children }: AuthProviderProps) { const [isAvailable, setIsAvailable] = React.useState(true); const [isAuthenticated, setIsAuthenticated] = React.useState(false); const [jwt, setJwt] = React.useState(); + const [permissions, setPermissions] = React.useState([]); const checkStore = React.useCallback(() => { if (account) { @@ -113,13 +115,21 @@ export function AuthProvider({ children }: AuthProviderProps) { } }, [account, connector, provider]); + const reset = React.useCallback(() => { + setIsAuthenticated(false); + setJwt(undefined); + setPermissions([]); + }, []); + const login = React.useCallback(async () => { if (account) { // try to load from cache first let token = checkStore(); if (token) { console.debug("Using cached jwt"); + const payload = decodeToken(token); setJwt(token); + setPermissions((payload as JwtPayload)?.permissions ?? []) setIsAuthenticated(true); return; } @@ -127,7 +137,9 @@ export function AuthProvider({ children }: AuthProviderProps) { // regular authentication token = await acquireToken(); if (token) { + const payload = decodeToken(token); setJwt(token); + setPermissions((payload as JwtPayload)?.permissions ?? []) addToken(account, token); setIsAuthenticated(true); return; @@ -139,9 +151,8 @@ export function AuthProvider({ children }: AuthProviderProps) { if (account) { removeToken(account); } - setIsAuthenticated(false); - setJwt(undefined); - }, [account, removeToken]); + reset(); + }, [account, removeToken, reset]); // check backend service availability React.useEffect(() => { @@ -182,8 +193,7 @@ export function AuthProvider({ children }: AuthProviderProps) { } catch (err) { console.debug(err); if (mounted) { - setIsAuthenticated(false); - setJwt(undefined); + reset(); } } }; @@ -192,15 +202,14 @@ export function AuthProvider({ children }: AuthProviderProps) { if (account) { load(); } else { - setIsAuthenticated(false); - setJwt(undefined); + reset() } } return () => { mounted = false; }; - }, [account, networkId, isAuto, isAvailable]); + }, [account, networkId, isAuto, isAvailable, login, reset]); return ( Date: Tue, 4 Jul 2023 15:07:06 +0200 Subject: [PATCH 029/135] check permissions on pages --- src/components/ContentWrapper.tsx | 6 +++--- src/components/Header.tsx | 10 +++++++--- src/pages/AttendeePage.tsx | 2 +- src/pages/HomePage.tsx | 2 +- src/pages/OrganizerPage.tsx | 23 +++++++++++++++-------- 5 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/components/ContentWrapper.tsx b/src/components/ContentWrapper.tsx index 0b981fb..51ced7e 100644 --- a/src/components/ContentWrapper.tsx +++ b/src/components/ContentWrapper.tsx @@ -13,11 +13,11 @@ export type ContentWrapperProps = { tooltip?: ReactNode; secondary?: ReactNode; isLoading?: boolean; - isAuthenticated?: boolean; + isAuthorized?: boolean; }; export function ContentWrapper(props: ContentWrapperProps) { - const { children, title, tooltip, secondary, isLoading, isAuthenticated } = + const { children, title, tooltip, secondary, isLoading, isAuthorized } = props; return ( @@ -39,7 +39,7 @@ export function ContentWrapper(props: ContentWrapperProps) {
    )} - {isAuthenticated ? ( + {isAuthorized ? ( isLoading ? ( ) : ( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 6a88c03..ee377f8 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -45,18 +45,22 @@ const StyledList = styled(MuiList)<{ component?: React.ElementType }>( function Header() { const { isActive } = useWeb3(); - const { isAuthenticated } = useAuth(); + const { isAuthenticated, permissions } = useAuth(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); const entries: Array<[string, string, boolean]> = React.useMemo( () => [ ["Home", "/", false], - ["Organizer", "/organizer", !(isActive && isAuthenticated)], + ["Organizer", "/organizer", !(isActive && isAuthorized)], ["Attendee", "/attendee", !(isActive && isAuthenticated)], ...(config.debug ? ([["Debug", "/debug", false]] as Array<[string, string, boolean]>) : []), ], - [isActive, isAuthenticated] + [isActive, isAuthenticated, isAuthorized] ); return ( diff --git a/src/pages/AttendeePage.tsx b/src/pages/AttendeePage.tsx index e39ec60..d382de1 100644 --- a/src/pages/AttendeePage.tsx +++ b/src/pages/AttendeePage.tsx @@ -85,7 +85,7 @@ function AttendeePage() { ; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 876d78a..b08de67 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -91,7 +91,7 @@ function HomePage() { title="Public Events Overview" tooltip={tooltip} isLoading={!Boolean(data)} - isAuthenticated={true} + isAuthorized={true} > diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index bab584c..d8b9655 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -15,11 +15,15 @@ import { useAuth } from "components/AuthContext"; function OrganizerPage() { const { isActive, networkId } = useWeb3(); - const { isAuthenticated, jwt } = useAuth(); + const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); + React.useEffect(() => { let mounted = true; @@ -42,9 +46,12 @@ function OrganizerPage() { setData(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar(`Failed to load events data: ${err.response?.data.error}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to load events data: ${err.response?.data.error}`, + { + variant: "error", + } + ); } else { enqueueSnackbar("Failed to load events data", { variant: "error", @@ -55,7 +62,7 @@ function OrganizerPage() { // only update data, if no dialog is open if (!activeDialog.type) { - if (isAuthenticated) { + if (isAuthorized) { load(); } else { setData(undefined); @@ -65,7 +72,7 @@ function OrganizerPage() { return () => { mounted = false; }; - }, [activeDialog, isActive, networkId, isAuthenticated, jwt]); + }, [activeDialog, isActive, networkId, isAuthorized, jwt]); const rows = React.useMemo(() => { if (data) { @@ -90,14 +97,14 @@ function OrganizerPage() { New From a86f0f6b56692a75ee3d03cbe6985972aa9ee6ab Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 5 Jul 2023 09:48:46 +0200 Subject: [PATCH 030/135] make network status indicator non clickable --- src/components/NetworkStatus.tsx | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/components/NetworkStatus.tsx b/src/components/NetworkStatus.tsx index b9fd0dc..3026170 100644 --- a/src/components/NetworkStatus.tsx +++ b/src/components/NetworkStatus.tsx @@ -1,5 +1,7 @@ import React from "react"; -import Button from "@mui/material/Button"; + +import Chip from "@mui/material/Chip"; + import { useWeb3 } from "connectors/context"; import { NetworkIdentifier } from "types"; @@ -22,17 +24,17 @@ function NetworkStatus() { }, [networkId]); return ( - - - + ); } From db661421d688488bbc362c6253aeb9b8a58204a9 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 5 Jul 2023 14:27:43 +0200 Subject: [PATCH 031/135] clean up event info page --- src/components/AttendeeTable.tsx | 88 +++++++++++ src/pages/EventInfoPage.tsx | 250 +++++++++++++------------------ 2 files changed, 194 insertions(+), 144 deletions(-) create mode 100644 src/components/AttendeeTable.tsx diff --git a/src/components/AttendeeTable.tsx b/src/components/AttendeeTable.tsx new file mode 100644 index 0000000..8eff251 --- /dev/null +++ b/src/components/AttendeeTable.tsx @@ -0,0 +1,88 @@ +import React from "react"; + +import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined"; +import { + GridActionsCellItem, + GridColDef, + GridTreeNodeWithRender, + GridValueGetterParams, +} from "@mui/x-data-grid"; + +import DataTable from "components/DataTable"; + +export type AttendanceTableRow = { + id: number; + index: number; + walletAddress: string; + firstName?: string; + lastName?: string; + email?: string; +}; + +export type AttendanceTableProps = { + rows: AttendanceTableRow[]; +}; + +type GetterParamsType = GridValueGetterParams< + AttendanceTableRow, + any, + GridTreeNodeWithRender +>; + +export function AttendanceTable(props: AttendanceTableProps) { + const { rows } = props; + + const columns = React.useMemo[]>( + () => [ + { + field: "index", + headerName: "#", + type: "number", + width: 45, + minWidth: 45, + }, + { + field: "walletAddress", + headerName: "Wallet Address", + type: "string", + width: 320, + }, + { + field: "name", + headerName: "Name", + type: "string", + flex: 1, + valueGetter: ({ row }: GetterParamsType) => { + let name = ""; + if (row.firstName) { + name += row.firstName; + } + if (row.lastName) { + name += ` ${row.lastName}`; + } + return name.trim(); + }, + }, + { + field: "actions", + type: "actions", + width: 50, + getActions: (params) => [ + } + label="Send Email" + disabled={!params.row.email} + onClick={() => + (window.location.href = `mailto:${params.row.email}`) + } + />, + ], + }, + ], + [] + ); + + return ; +} + +export default AttendanceTable; diff --git a/src/pages/EventInfoPage.tsx b/src/pages/EventInfoPage.tsx index 9cdf958..983447d 100644 --- a/src/pages/EventInfoPage.tsx +++ b/src/pages/EventInfoPage.tsx @@ -5,18 +5,16 @@ import { useSnackbar } from "notistack"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; -import Paper from "@mui/material/Paper"; import Typography from "@mui/material/Typography"; -import Link from "@mui/material/Link"; -import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined"; import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; -import type { GridColDef } from "@mui/x-data-grid"; import API from "apis"; import type { Event, Metadata } from "types"; import Loader from "components/Loader"; -import DataTable from "components/DataTable"; + import { useAuth } from "components/AuthContext"; +import AttendanceTable, { AttendanceTableRow } from "components/AttendeeTable"; +import ContentWrapper from "components/ContentWrapper"; function EventInfoPage() { const { jwt } = useAuth(); @@ -42,9 +40,12 @@ function EventInfoPage() { setData(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar(`Failed to load event data: ${err.response?.data.error}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to load event data: ${err.response?.data.error}`, + { + variant: "error", + } + ); } else { enqueueSnackbar("Failed to load event data", { variant: "error", @@ -54,7 +55,11 @@ function EventInfoPage() { }; if (id) { - load(); + if (parseInt(id)) { + load(); + } else { + setData(null); // invalid, event not found + } } else { setData(undefined); } @@ -83,9 +88,12 @@ function EventInfoPage() { setMetadata(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar(`Failed to load event metadata: ${err.response?.data.error}`, { - variant: "error", - }); + enqueueSnackbar( + `Failed to load event metadata: ${err.response?.data.error}`, + { + variant: "error", + } + ); } else { enqueueSnackbar("Failed to load event metadata", { variant: "error", @@ -105,54 +113,15 @@ function EventInfoPage() { }; }, [data]); - const columns: GridColDef[] = [ - { field: "index", headerName: "#", width: 45, minWidth: 45 }, - { field: "address", headerName: "Wallet Address", width: 320 }, - { field: "name", headerName: "Name", flex: 1 }, - { - field: "email", - headerName: "Email", - align: "center", - sortable: false, - filterable: false, - width: 60, - renderCell: (params) => { - return ( - - {params.row.emailAddress && ( - - - - )} - - ); - }, - }, - ]; - - const makeName = (first?: string, last?: string): string => { - let result = ""; - if (first) { - result += first; - } - if (last) { - result += ` ${last}`; - } - return result.trim(); - }; - - const rows = React.useMemo(() => { + const rows = React.useMemo(() => { if (data && data.attendees) { return data.attendees.map((a, i) => ({ id: i, index: i + 1, - address: a.walletAddress, - name: makeName(a.firstName, a.lastName), - emailAddress: a.email, + walletAddress: a.walletAddress, + firstName: a.firstName, + lastName: a.lastName, + email: a.email, })); } else { return []; @@ -160,110 +129,103 @@ function EventInfoPage() { }, [data]); return ( - - - - {data ? ( + navigate(-1)} + > + + + } + > + {data ? ( + + + + {data.title} + + {`Event #${data.id}`} + + + {metadata ? ( - - {data.title} - - {`Event #${data.id}`} + alt="event banner" + src={metadata.imageUrl} + /> + {metadata.description} - {metadata ? ( - - - - - {metadata.description} - - - - - - Information: - - - Date Start: {metadata.dateStart} - - - Date End: {metadata.dateEnd} - - - Location: {metadata.location} - - - Reserved slots: {data.attendees?.length}/ - {metadata.tokenCount} - - - - - - Attendees: - - - - - ) : ( - - )} + + + Information: + + + Date Start:{" "} + {new Date(metadata.dateStart).toString()} + + + Date End:{" "} + {new Date(metadata.dateEnd).toString()} + + + Location: {metadata.location} + + + Reserved slots: {data.attendees?.length}/ + {metadata.tokenCount} + + - ) : data === null ? ( - Event not found! ) : ( )} - navigate(-1)} - > - - - - - + + + Attendees: + + + + + ) : ( + // data === null + Event not found! + )} + ); } From b7cff65abae2376032ce89ef9734e28829cc6792 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 5 Jul 2023 15:15:01 +0200 Subject: [PATCH 032/135] add link to event info --- src/components/EventTable.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index ef69391..a3a296c 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link as NavLink } from "react-router-dom"; import { useSetAtom } from "jotai"; +import Link from "@mui/material/Link"; import EventAvailableIcon from "@mui/icons-material/EventAvailable"; import FileCopyIcon from "@mui/icons-material/FileCopy"; import GroupAddIcon from "@mui/icons-material/GroupAdd"; @@ -86,7 +87,21 @@ export function EventTable(props: EventTableProps) { width: 45, minWidth: 45, }, - { field: "title", headerName: "Title", type: "string", flex: 1 }, + { + field: "title", + headerName: "Title", + type: "string", + flex: 1, + renderCell: (params) => ( + + {params.value} + + ), + }, { field: "dateStart", headerName: "Start", From ba500d741985d9e62490e76bc3eaa13de645d788 Mon Sep 17 00:00:00 2001 From: Riku Date: Fri, 7 Jul 2023 14:33:54 +0200 Subject: [PATCH 033/135] snackbar tweaks --- src/components/JoinDialog.tsx | 3 +++ src/components/MintDialog.tsx | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index 938f73f..d9423ff 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -59,6 +59,9 @@ function JoinDialog() { eventId: data.eventId, }); console.debug("JoinResult", offer); + enqueueSnackbar(`Sign-up successful: Event #${data?.eventId}`, { + variant: "success", + }); if (!offer.claimed) { if (checked) { diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index cb82f91..30332a7 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -170,7 +170,7 @@ function MintDialog(props: MintDialogProps) { isManaged: !values.isPublic, }); console.debug("MintResult", result); - enqueueSnackbar(`Mint successful: Event ID #${result.eventId}`, { + enqueueSnackbar(`Mint successful: Event #${result.eventId}`, { variant: "success", }); reset(); From 140f688694574a4aa9aed2675090df0aee37a1b4 Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 11 Jul 2023 13:49:10 +0200 Subject: [PATCH 034/135] implement join api changes --- src/apis/event.ts | 1 + src/components/ClaimDialog.tsx | 2 +- src/components/JoinDialog.tsx | 41 +++++++++++++++------------------- src/types.ts | 2 +- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/src/apis/event.ts b/src/apis/event.ts index e3669ac..9695e85 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -42,6 +42,7 @@ export const create = async ( export type joinData = { eventId: number; + createOffer: boolean; }; export const join = async (jwt: string, data: joinData): Promise => { diff --git a/src/components/ClaimDialog.tsx b/src/components/ClaimDialog.tsx index eaf2d65..da6b12c 100644 --- a/src/components/ClaimDialog.tsx +++ b/src/components/ClaimDialog.tsx @@ -55,7 +55,7 @@ function ClaimDialog() { }); console.debug("ClaimResult", offer); - if (!offer.claimed) { + if (offer.offerIndex && !offer.claimed) { enqueueSnackbar( "Creating NFT claim request (confirm the transaction in your wallet)", { diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index d9423ff..012d327 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -57,37 +57,32 @@ function JoinDialog() { if (provider && account && data?.eventId && jwt) { const offer = await API.event.join(jwt, { eventId: data.eventId, + createOffer: checked, }); console.debug("JoinResult", offer); enqueueSnackbar(`Sign-up successful: Event #${data?.eventId}`, { variant: "success", }); - if (!offer.claimed) { - if (checked) { - enqueueSnackbar( - "Creating NFT claim request (confirm the transaction in your wallet)", - { - variant: "warning", - autoHideDuration: 30000, - } - ); - const success = await provider.acceptOffer(offer.offerIndex); - - if (success) { - enqueueSnackbar("Claim successful", { - variant: "success", - }); - } else { - enqueueSnackbar(`Claim failed: Unable to claim NFT`, { - variant: "error", - }); + if (checked && offer.offerIndex) { + enqueueSnackbar( + "Creating NFT claim request (confirm the transaction in your wallet)", + { + variant: "warning", + autoHideDuration: 30000, } + ); + const success = await provider.acceptOffer(offer.offerIndex); + + if (success) { + enqueueSnackbar("Claim successful", { + variant: "success", + }); + } else { + enqueueSnackbar(`Claim failed: Unable to claim NFT`, { + variant: "error", + }); } - } else { - enqueueSnackbar(`Claim successful: Already claimed NFT`, { - variant: "success", - }); } } } catch (err) { diff --git a/src/types.ts b/src/types.ts index d324ec8..ed4e969 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,7 +61,7 @@ export type Offer = { id: number; ownerWalletAddress: User["walletAddress"]; tokenId: NFT["id"]; - offerIndex: string; + offerIndex: string | null; claimed: boolean; owner?: User; token: NFT; From e190ee6d72e53b3dd77f2e8a4a0445979efac79d Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 11 Jul 2023 14:59:01 +0200 Subject: [PATCH 035/135] update types --- src/types.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/types.ts b/src/types.ts index ed4e969..94ee9aa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,16 +25,24 @@ export enum ConnectorType { GEM = "GEM", } +export enum EventStatus { + ACTIVE, + CLOSED, + CANCELED, +} + export type User = { walletAddress: string; firstName?: string; lastName?: string; email?: string; isOrganizer: boolean; + slots: number; }; export type Event = { id: number; + status: EventStatus; networkId: NetworkIdentifier; title: string; description: string; From 8871464a7e92792e653a4fb5a4def4c6de096317 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 12 Jul 2023 11:01:06 +0200 Subject: [PATCH 036/135] add stack for secondary button spacing --- src/components/ContentWrapper.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/ContentWrapper.tsx b/src/components/ContentWrapper.tsx index 51ced7e..c99c0cf 100644 --- a/src/components/ContentWrapper.tsx +++ b/src/components/ContentWrapper.tsx @@ -1,8 +1,6 @@ import type { ReactNode } from "react"; -import Box from "@mui/material/Box"; -import Paper from "@mui/material/Paper"; -import Typography from "@mui/material/Typography"; +import { Box, Paper, Stack, Typography } from "@mui/material"; import HelpButton from "components/HelpButton"; import Loader from "components/Loader"; @@ -27,10 +25,12 @@ export function ContentWrapper(props: ContentWrapperProps) { square > - {secondary} - {tooltip && ( - - )} + + {secondary} + {tooltip && ( + + )} + {title && ( From 3eb7b0a1c55cc6a4f14af13ac4d3479faabf77a5 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 12 Jul 2023 11:01:43 +0200 Subject: [PATCH 037/135] add get slots api --- src/apis/user.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/apis/user.ts b/src/apis/user.ts index 7c3ebaa..87c0dee 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -1,7 +1,7 @@ import axios from "axios"; import config from "config"; -import type { User } from "types"; +import type { NetworkIdentifier, User } from "types"; export type getInfoParams = { includeEvents?: boolean; @@ -50,3 +50,28 @@ export const update = async ( return response.data.result as boolean; }; + +export type getSlotsParams = { + networkId: NetworkIdentifier; +}; + +export type getSlotsResult = [number, number]; + +export const getSlots = async ( + jwt: string, + params: getSlotsParams +): Promise => { + const response = await axios.get( + new URL("/user/slots", config.apiURL).toString(), + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + params: params, + } + ); + + return response.data.result as getSlotsResult; +}; From ca3a283e2ed43953b04d09674accff8fd25b293e Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 12 Jul 2023 11:03:45 +0200 Subject: [PATCH 038/135] display available slots --- src/pages/OrganizerPage.tsx | 47 +++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index d8b9655..71ef3dd 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -3,7 +3,7 @@ import axios from "axios"; import { useSnackbar } from "notistack"; import { useAtom } from "jotai"; -import Button from "@mui/material/Button"; +import { Box, Button } from "@mui/material"; import API from "apis"; import { useWeb3 } from "connectors/context"; @@ -17,6 +17,7 @@ function OrganizerPage() { const { isActive, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); + const [slots, setSlots] = React.useState<{ used: number; max: number }>(); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); @@ -36,14 +37,23 @@ function OrganizerPage() { includeAttendees: true, }); + const [usedSlots, maxSlots] = await API.user.getSlots(jwt, { + networkId: networkId, + }); + if (mounted) { setData(events); + setSlots({ + used: usedSlots, + max: maxSlots, + }); } } } catch (err) { console.debug(err); if (mounted) { setData(undefined); + setSlots(undefined); } if (axios.isAxiosError(err)) { enqueueSnackbar( @@ -66,6 +76,7 @@ function OrganizerPage() { load(); } else { setData(undefined); + setSlots(undefined); } } @@ -99,15 +110,31 @@ function OrganizerPage() { isLoading={!Boolean(data)} isAuthorized={isAuthorized} secondary={ - + + {slots && ( + + + Slots: {slots.used}/{slots.max} + + + )} + + } > From 4ae0b21c39223e52aba01a1ef38520d7a3e96cda Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 12 Jul 2023 11:48:15 +0200 Subject: [PATCH 039/135] add event status to data table --- src/components/EventTable.tsx | 3 ++- src/pages/AttendeePage.tsx | 1 + src/pages/HomePage.tsx | 1 + src/pages/OrganizerPage.tsx | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index a3a296c..d3c79c3 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -15,12 +15,13 @@ import { } from "@mui/x-data-grid"; import { useWeb3 } from "connectors/context"; -import { DialogIdentifier } from "types"; +import { DialogIdentifier, EventStatus } from "types"; import DataTable from "components/DataTable"; import { activeDialogAtom } from "states/atoms"; export type EventTableRow = { id: number; + status: EventStatus; title: string; dateStart: Date; dateEnd: Date; diff --git a/src/pages/AttendeePage.tsx b/src/pages/AttendeePage.tsx index d382de1..51d185f 100644 --- a/src/pages/AttendeePage.tsx +++ b/src/pages/AttendeePage.tsx @@ -68,6 +68,7 @@ function AttendeePage() { if (data) { return data.map((offer) => ({ id: offer.token.eventId, + status: offer.token.event.status, title: offer.token.event.title, dateStart: new Date(offer.token.event.dateStart), dateEnd: new Date(offer.token.event.dateEnd), diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index b08de67..9172ade 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -75,6 +75,7 @@ function HomePage() { if (data) { return data.map((event) => ({ id: event.id, + status: event.status, title: event.title, dateStart: new Date(event.dateStart), dateEnd: new Date(event.dateEnd), diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index 71ef3dd..e0ecc6e 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -89,6 +89,7 @@ function OrganizerPage() { if (data) { return data.map((event) => ({ id: event.id, + status: event.status, title: event.title, dateStart: new Date(event.dateStart), dateEnd: new Date(event.dateEnd), From df08972736183bcacedacd631d5131e319e20827 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 12 Jul 2023 11:49:02 +0200 Subject: [PATCH 040/135] fix offer index typing --- src/components/EventTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index d3c79c3..dda7e25 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -28,7 +28,7 @@ export type EventTableRow = { slotsTaken?: number; slotsTotal: number; claimed?: boolean; - offerIndex?: string; + offerIndex?: string | null; }; export type EventTableProps = { From f683a2e86292b6e342e0462e20023c278bcc30e9 Mon Sep 17 00:00:00 2001 From: Riku Date: Wed, 12 Jul 2023 13:02:37 +0200 Subject: [PATCH 041/135] style table depending on event expiration --- src/components/DataTable.tsx | 10 ++- src/components/EventTable.tsx | 115 ++++++++++++++++++++++------------ 2 files changed, 82 insertions(+), 43 deletions(-) diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index 2af142a..1ab5ece 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -1,20 +1,23 @@ -import Box from "@mui/material/Box"; +import { Box, SxProps, Theme } from "@mui/material"; import { DataGrid, GridColDef, gridClasses, GridOverlay, + GridRowClassNameParams, } from "@mui/x-data-grid"; type DataTableProps = { + sx?: SxProps; columns: GridColDef[]; rows: any[]; + rowClassName?: (params: GridRowClassNameParams) => string; }; function DataTable(props: DataTableProps) { - const { columns, rows } = props; + const { sx, columns, rows, rowClassName } = props; return ( - + No data, }} + getRowClassName={rowClassName} /> ); diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index dda7e25..4fc519f 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -1,6 +1,7 @@ import React from "react"; import { useNavigate, Link as NavLink } from "react-router-dom"; import { useSetAtom } from "jotai"; +import clsx from "clsx"; import Link from "@mui/material/Link"; import EventAvailableIcon from "@mui/icons-material/EventAvailable"; @@ -10,6 +11,7 @@ import InfoIcon from "@mui/icons-material/Info"; import { GridActionsCellItem, GridColDef, + GridRowClassNameParams, GridTreeNodeWithRender, GridValueGetterParams, } from "@mui/x-data-grid"; @@ -79,6 +81,12 @@ export function EventTable(props: EventTableProps) { [setActiveDialog] ); + const rowClassName = (params: GridRowClassNameParams) => { + return clsx("data-table", { + expired: params.row.status !== EventStatus.ACTIVE, + }); + }; + const columns = React.useMemo[]>( () => [ { @@ -93,15 +101,21 @@ export function EventTable(props: EventTableProps) { headerName: "Title", type: "string", flex: 1, - renderCell: (params) => ( - - {params.value} - - ), + renderCell: (params) => { + if (params.row.status === EventStatus.ACTIVE) { + return ( + + {params.value} + + ); + } else { + return
    {params.value}
    ; + } + }, }, { field: "dateStart", @@ -109,7 +123,12 @@ export function EventTable(props: EventTableProps) { type: "date", width: 100, }, - { field: "dateEnd", headerName: "End", type: "date", width: 100 }, + { + field: "dateEnd", + headerName: "End", + type: "date", + width: 100, + }, ...(isAttendee ? [ { @@ -139,35 +158,40 @@ export function EventTable(props: EventTableProps) { type: "actions", width: 45, minWidth: 45, - getActions: (params) => [ - } - label="Add Participant" - onClick={() => handleAdd(params.row.id)} - disabled={!(isActive && isOwner)} - showInMenu - />, - } - label="Join Event" - onClick={() => handleJoin(params.row.id, params.row.title)} - disabled={!(isActive && !isAttendee)} - showInMenu - />, - } - label="Claim NFT" - onClick={() => handleClaim(params.row.id)} - disabled={!(isActive && isAttendee && !params.row.claimed)} - showInMenu - />, - } - label="Show Details" - onClick={() => navigate(`/event/${params.row.id}`)} - showInMenu - />, - ], + getActions: (params) => { + const active = params.row.status === EventStatus.ACTIVE; + return [ + } + label="Add Participant" + onClick={() => handleAdd(params.row.id)} + disabled={!(active && isActive && isOwner)} + showInMenu + />, + } + label="Join Event" + onClick={() => handleJoin(params.row.id, params.row.title)} + disabled={!(active && isActive && !isAttendee)} + showInMenu + />, + } + label="Claim NFT" + onClick={() => handleClaim(params.row.id)} + disabled={ + !(active && isActive && isAttendee && !params.row.claimed) + } + showInMenu + />, + } + label="Show Details" + onClick={() => navigate(`/event/${params.row.id}`)} + showInMenu + />, + ]; + }, }, ], [ @@ -181,7 +205,18 @@ export function EventTable(props: EventTableProps) { ] ); - return ; + return ( + theme.palette.grey[500], + }, + }} + columns={columns} + rows={rows} + rowClassName={rowClassName} + /> + ); } export default EventTable; From 871dfc2624473cebd45ca24c985efc9adf1454c3 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 13 Jul 2023 16:46:56 +0200 Subject: [PATCH 042/135] add user wallet address auto complete options --- src/apis/index.ts | 2 ++ src/apis/users.ts | 19 +++++++++++++ src/components/AddDialog.tsx | 55 ++++++++++++++++++++++++++++++++++-- 3 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/apis/users.ts diff --git a/src/apis/index.ts b/src/apis/index.ts index 4ecae83..69b8623 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -3,6 +3,7 @@ import * as event from "./event"; import * as events from "./events"; import * as offers from "./offers"; import * as user from "./user"; +import * as users from "./users"; export const API = { auth, @@ -10,6 +11,7 @@ export const API = { events, offers, user, + users, }; export default API; diff --git a/src/apis/users.ts b/src/apis/users.ts new file mode 100644 index 0000000..f39f42e --- /dev/null +++ b/src/apis/users.ts @@ -0,0 +1,19 @@ +import axios from "axios"; +import config from "config"; + +export const getAll = async ( + jwt: string, +): Promise => { + const response = await axios.get( + new URL("/users", config.apiURL).toString(), + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + } + ); + + return response.data.result as string[]; +}; diff --git a/src/components/AddDialog.tsx b/src/components/AddDialog.tsx index f160d9e..00cea7b 100644 --- a/src/components/AddDialog.tsx +++ b/src/components/AddDialog.tsx @@ -78,10 +78,11 @@ type AddDialogData = Record; function AddDialog() { const { account } = useWeb3(); - const { isAuthenticated, jwt } = useAuth(); + const { isAuthenticated, jwt, permissions } = useAuth(); const [open, setOpen] = React.useState(false); const [data, setData] = React.useState(); const [loading, setLoading] = React.useState(false); + const [options, setOptions] = React.useState([]); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); @@ -96,14 +97,62 @@ function AddDialog() { resolver: zodResolver(schema), }); - // no default options for now - const options: AddFormValues["addresses"] = []; + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); React.useEffect(() => { setOpen(activeDialog.type === DialogIdentifier.DIALOG_ADD); setData(activeDialog.data); }, [activeDialog]); + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (jwt) { + const addresses = await API.users.getAll(jwt); + + if (mounted) { + setOptions( + addresses.map((address) => { + return { address: address }; + }) + ); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setOptions([]); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load users data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load users data", { + variant: "error", + }); + } + } + }; + + if (isAuthorized) { + load(); + } else { + setOptions([]); + } + + return () => { + mounted = false; + }; + }, [isAuthorized, jwt]); + const handleClose = (event: {}, reason?: string) => { if (reason === "backdropClick") { return; From 3fd6d071d35ad34543a80e30f1fb27ce4436b5d2 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 20 Jul 2023 07:57:32 +0200 Subject: [PATCH 043/135] add debug page --- src/pages/DebugPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 src/pages/DebugPage.tsx diff --git a/src/pages/DebugPage.tsx b/src/pages/DebugPage.tsx new file mode 100644 index 0000000..4e1ca52 --- /dev/null +++ b/src/pages/DebugPage.tsx @@ -0,0 +1,7 @@ +import React from "react"; + +function DebugPage() { + return ; +} + +export default DebugPage; From 0815276887a98d93e5964faab6c64d534731fd10 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 20 Jul 2023 07:58:34 +0200 Subject: [PATCH 044/135] clean up dialog set data --- src/components/AddDialog.tsx | 3 +++ src/components/ClaimDialog.tsx | 6 ++++-- src/components/JoinDialog.tsx | 3 +++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/AddDialog.tsx b/src/components/AddDialog.tsx index 00cea7b..1b5e8f9 100644 --- a/src/components/AddDialog.tsx +++ b/src/components/AddDialog.tsx @@ -157,11 +157,13 @@ function AddDialog() { if (reason === "backdropClick") { return; } + setData(undefined); setActiveDialog({}); }; const handleCancel = (event: React.MouseEvent) => { reset(); + setData(undefined); setActiveDialog({}); }; @@ -197,6 +199,7 @@ function AddDialog() { } } finally { setLoading(false); + setData(undefined); setActiveDialog({}); } }; diff --git a/src/components/ClaimDialog.tsx b/src/components/ClaimDialog.tsx index da6b12c..7afea30 100644 --- a/src/components/ClaimDialog.tsx +++ b/src/components/ClaimDialog.tsx @@ -39,10 +39,12 @@ function ClaimDialog() { if (reason === "backdropClick") { return; } + setData(undefined); setActiveDialog({}); }; const handleCancel = (event: React.MouseEvent) => { + setData(undefined); setActiveDialog({}); }; @@ -93,9 +95,9 @@ function ClaimDialog() { } } finally { setLoading(false); + setData(undefined); + setActiveDialog({}); } - - setActiveDialog({}); }; return ( diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index 012d327..eb0314b 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -44,10 +44,12 @@ function JoinDialog() { if (reason === "backdropClick") { return; } + setData(undefined); setActiveDialog({}); }; const handleCancel = (event: React.MouseEvent) => { + setData(undefined); setActiveDialog({}); }; @@ -98,6 +100,7 @@ function JoinDialog() { } } finally { setLoading(false); + setData(undefined); setActiveDialog({}); } }; From a22376081d2c5289caa6657cc090d2fa2868a221 Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 23 Jul 2023 12:02:54 +0200 Subject: [PATCH 045/135] make loader text customizable --- src/components/Loader.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx index d73e140..e1c645a 100644 --- a/src/components/Loader.tsx +++ b/src/components/Loader.tsx @@ -2,13 +2,16 @@ import Box from "@mui/material/Box"; import CircularProgress from "@mui/material/CircularProgress"; import Typography from "@mui/material/Typography"; -function Loader() { +type LoaderProps = { + text?: string; +}; + +function Loader(props: LoaderProps) { + const { text } = props; return ( - - Loading - + {text ?? "Loading"} ); } From 5f298e400d713f57bcf2034f37f7d2f5ca62c413 Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 23 Jul 2023 12:03:58 +0200 Subject: [PATCH 046/135] add get event link api --- src/apis/event.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/apis/event.ts b/src/apis/event.ts index 9695e85..499f7cb 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -130,3 +130,21 @@ export const getInfo = async ( return response.data.result as getInfoResult; }; + +export const getLink = async ( + jwt: string, + id: string | number +): Promise => { + const response = await axios.get( + new URL(`/event/link/${id}`, config.apiURL).toString(), + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + } + ); + + return response.data.result as string; +}; From 0dad78d9420632a2fd6babd82398695a8e3a3e4e Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 23 Jul 2023 14:23:48 +0200 Subject: [PATCH 047/135] add create link dialog --- package.json | 3 + src/components/EventTable.tsx | 21 +++- src/components/LinkDialog.tsx | 216 ++++++++++++++++++++++++++++++++++ src/layouts/MainLayout.tsx | 2 + src/types.ts | 1 + 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/components/LinkDialog.tsx diff --git a/package.json b/package.json index 475f5be..1126de5 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,10 @@ "@mui/x-date-pickers": "^6.8.0", "axios": "^1.4.0", "date-fns": "^2.30.0", + "file-saver": "^2.0.5", "jotai": "^2.1.0", "notistack": "^3.0.1", + "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.45.0", @@ -59,6 +61,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", + "@types/file-saver": "^2.0.5", "@types/jest": "^27.0.1", "@types/node": "^16.7.13", "@types/react": "^18.0.0", diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index 4fc519f..27dab18 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -8,6 +8,7 @@ import EventAvailableIcon from "@mui/icons-material/EventAvailable"; import FileCopyIcon from "@mui/icons-material/FileCopy"; import GroupAddIcon from "@mui/icons-material/GroupAdd"; import InfoIcon from "@mui/icons-material/Info"; +import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; import { GridActionsCellItem, GridColDef, @@ -61,6 +62,16 @@ export function EventTable(props: EventTableProps) { [setActiveDialog] ); + const handleLink = React.useCallback( + (id: number) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_LINK, + data: { eventId: id }, + }); + }, + [setActiveDialog] + ); + const handleJoin = React.useCallback( (id: number, title: string) => { setActiveDialog({ @@ -168,6 +179,13 @@ export function EventTable(props: EventTableProps) { disabled={!(active && isActive && isOwner)} showInMenu />, + } + label="Create Link" + onClick={() => handleLink(params.row.id)} + disabled={!(active && isActive && isOwner)} + showInMenu + />, } label="Join Event" @@ -196,11 +214,12 @@ export function EventTable(props: EventTableProps) { ], [ isActive, - isOwner, isAttendee, + isOwner, handleAdd, handleClaim, handleJoin, + handleLink, navigate, ] ); diff --git a/src/components/LinkDialog.tsx b/src/components/LinkDialog.tsx new file mode 100644 index 0000000..4a0ae2b --- /dev/null +++ b/src/components/LinkDialog.tsx @@ -0,0 +1,216 @@ +import React from "react"; +import axios from "axios"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import { QRCodeCanvas } from "qrcode.react"; +import { saveAs } from "file-saver"; + +import { + Button, + Box, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Tooltip, +} from "@mui/material"; + +import CloseIcon from "@mui/icons-material/Close"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; + +import API from "apis"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; +import { useAuth } from "components/AuthContext"; +import Loader from "components/Loader"; + +type LinkDialogData = Record; + +function LinkDialog() { + const { isAuthenticated, jwt, permissions } = useAuth(); + const [open, setOpen] = React.useState(false); + const [data, setData] = React.useState(); + const [link, setLink] = React.useState(); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); + + React.useEffect(() => { + setOpen(activeDialog.type === DialogIdentifier.DIALOG_LINK); + setData(activeDialog.data); + }, [activeDialog]); + + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (jwt && data?.eventId) { + const masked = await API.event.getLink(jwt, data.eventId); + if (mounted) { + setLink(`${window.location.origin}/claim/${masked}`); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setLink(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load event link: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load event link", { + variant: "error", + }); + } + } + }; + + if (open && data) { + if (isAuthorized) { + load(); + } else { + setLink(undefined); + } + } + + return () => { + mounted = false; + }; + }, [open, data, jwt, isAuthorized]); + + const handleClose = (event: {}, reason?: string) => { + if (reason === "backdropClick") { + return; + } + setData(undefined); + setActiveDialog({}); + }; + + const handleConfirm = async (event: React.MouseEvent) => { + setData(undefined); + setActiveDialog({}); + }; + + const handleCopy = async (text: string) => { + await navigator.clipboard.writeText(text); + }; + + const handleDownload = () => { + const canvas = document.getElementById("qrcode") as HTMLCanvasElement; + if (canvas) { + canvas.toBlob((blob) => { + saveAs(blob!, "qr.png"); + }); + } + }; + + return ( + + + Invitation link for Event #{data?.eventId} + + theme.palette.grey[500], + }} + size="small" + onClick={handleClose} + > + + + + {link ? ( + + + + + theme.palette.grey[100], + border: "1px solid", + borderRadius: "4px", + borderColor: (theme) => theme.palette.grey[300], + padding: "0.75rem", + marginTop: "1rem", + position: "relative", + }} + > + + {link} + + + handleCopy(link)}> + + + + + + ) : ( + + )} + + + + + + + + ); +} + +export default LinkDialog; diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index df45e5f..5aa35c6 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -10,6 +10,7 @@ import AddDialog from "components/AddDialog"; import ClaimDialog from "components/ClaimDialog"; import Header from "components/Header"; import JoinDialog from "components/JoinDialog"; +import LinkDialog from "components/LinkDialog" import MintDialog from "components/MintDialog"; import ProfileDialog from "components/ProfileDialog"; @@ -33,6 +34,7 @@ function MainLayout(props: any) { + { // mounted component every time the dialog is opened to ensure diff --git a/src/types.ts b/src/types.ts index 94ee9aa..d7c44a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,7 @@ export enum DialogIdentifier { DIALOG_ADD, DIALOG_CLAIM, DIALOG_JOIN, + DIALOG_LINK, DIALOG_MINT, DIALOG_PROFILE, } From d58e075be50fd24976621e8097fbe0cabbe8841b Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 23 Jul 2023 14:32:24 +0200 Subject: [PATCH 048/135] set default event creation type to public --- src/components/MintDialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/MintDialog.tsx b/src/components/MintDialog.tsx index 30332a7..5b91431 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/MintDialog.tsx @@ -111,7 +111,7 @@ const defaultValues: DefaultValues = { dateStart: null, dateEnd: null, tokenCount: undefined, - isPublic: false, + isPublic: true, }; type MintDialogProps = { @@ -330,7 +330,7 @@ function MintDialog(props: MintDialogProps) { helperText={errors["tokenCount"]?.message} {...register("tokenCount", { valueAsNumber: true })} /> - ( @@ -361,7 +361,7 @@ function MintDialog(props: MintDialogProps) { /> )} - /> + /> */} From 210280960e999efff2cd24605a614a61c026d420 Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 23 Jul 2023 14:38:07 +0200 Subject: [PATCH 049/135] fix comment typo --- src/layouts/MainLayout.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 5aa35c6..35673ec 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -37,8 +37,8 @@ function MainLayout(props: any) { { - // mounted component every time the dialog is opened to ensure - // the latest values from the database are load + // mount component every time the dialog is opened to ensure + // the latest values from the database are loaded activeDialog.type === DialogIdentifier.DIALOG_PROFILE && ( ) From aefbacda1fe2b6f845a8205f4933e724077f7fcf Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 28 Aug 2023 14:54:30 +0200 Subject: [PATCH 050/135] add admin page template --- src/components/Header.tsx | 11 +++++--- src/pages/AdminPage.tsx | 56 +++++++++++++++++++++++++++++++++++++++ src/routes/MainRoutes.tsx | 9 +++++-- 3 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 src/pages/AdminPage.tsx diff --git a/src/components/Header.tsx b/src/components/Header.tsx index ee377f8..1447b65 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -47,20 +47,25 @@ function Header() { const { isActive } = useWeb3(); const { isAuthenticated, permissions } = useAuth(); - const isAuthorized = React.useMemo(() => { + const isOrganizer = React.useMemo(() => { return isAuthenticated && permissions.includes("organizer"); }, [isAuthenticated, permissions]); + const isAdmin = React.useMemo(() => { + return isAuthenticated && permissions.includes("admin"); + }, [isAuthenticated, permissions]); + const entries: Array<[string, string, boolean]> = React.useMemo( () => [ ["Home", "/", false], - ["Organizer", "/organizer", !(isActive && isAuthorized)], ["Attendee", "/attendee", !(isActive && isAuthenticated)], + ["Organizer", "/organizer", !(isActive && isOrganizer)], + ["Admin", "/admin", !(isActive && isAdmin)], ...(config.debug ? ([["Debug", "/debug", false]] as Array<[string, string, boolean]>) : []), ], - [isActive, isAuthenticated, isAuthorized] + [isActive, isAuthenticated, isOrganizer, isAdmin] ); return ( diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx new file mode 100644 index 0000000..bceafda --- /dev/null +++ b/src/pages/AdminPage.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import axios from "axios"; +import { useSnackbar } from "notistack"; +import { useAtom } from "jotai"; + +import { Box, Button } from "@mui/material"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { DialogIdentifier, Event } from "types"; +import { activeDialogAtom } from "states/atoms"; +import EventTable, { type EventTableRow } from "components/EventTable"; +import ContentWrapper from "components/ContentWrapper"; +import { useAuth } from "components/AuthContext"; + +function AdminPage() { + const { isActive, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + const [slots, setSlots] = React.useState<{ used: number; max: number }>(); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("admin"); + }, [isAuthenticated, permissions]); + + const rows = React.useMemo(() => { + if (data) { + return data.map((event) => ({ + id: event.id, + status: event.status, + title: event.title, + dateStart: new Date(event.dateStart), + dateEnd: new Date(event.dateEnd), + slotsTaken: event.attendees?.length, + slotsTotal: event.tokenCount, + })); + } else { + return []; + } + }, [data]); + + // TODO add EventTable isAdmin + return ( + + + + ); +} + +export default AdminPage; diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 9094205..7f71087 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -4,12 +4,13 @@ import type { RouteObject } from "react-router-dom"; import Loadable from "components/Loadable"; import MainLayout from "layouts/MainLayout"; +const AdminPage = Loadable(React.lazy(() => import("pages/AdminPage"))); +const AttendeePage = Loadable(React.lazy(() => import("pages/AttendeePage"))); const DebugPage = Loadable(React.lazy(() => import("pages/DebugPage"))); const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); const EventInfoPage = Loadable(React.lazy(() => import("pages/EventInfoPage"))); -const OrganizerPage = Loadable(React.lazy(() => import("pages/OrganizerPage"))); const HomePage = Loadable(React.lazy(() => import("pages/HomePage"))); -const AttendeePage = Loadable(React.lazy(() => import("pages/AttendeePage"))); +const OrganizerPage = Loadable(React.lazy(() => import("pages/OrganizerPage"))); const MainRoutes: RouteObject = { path: "/", @@ -32,6 +33,10 @@ const MainRoutes: RouteObject = { path: "/attendee", element: , }, + { + path: "/admin", + element: , + }, { path: "/debug", element: , From ccdbec72f90758b163ced0395e5a9104fb3d43cb Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 28 Aug 2023 15:14:42 +0200 Subject: [PATCH 051/135] add authorized minter api --- src/apis/event.ts | 25 ++++++++++++++++++++++++- src/types.ts | 5 +++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/apis/event.ts b/src/apis/event.ts index 499f7cb..93b8d8f 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -1,9 +1,32 @@ import axios from "axios"; import config from "config"; -import type { Event, Offer } from "types"; +import type { Event, Offer, Minter } from "types"; import { NetworkIdentifier } from "types"; +export type getMinterParams = { + networkId: NetworkIdentifier; +}; + +export const getMinter = async ( + jwt: string, + params: getMinterParams +): Promise => { + const response = await axios.get( + new URL("/event/minter", config.apiURL).toString(), + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + params: params, + } + ); + + return response.data.result as Minter; +}; + export type createData = { networkId: NetworkIdentifier; title: string; diff --git a/src/types.ts b/src/types.ts index d7c44a2..07c4eea 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,11 @@ export type User = { slots: number; }; +export type Minter = { + walletAddress: string; + isConfigured: boolean; +}; + export type Event = { id: number; status: EventStatus; From 5e8ccd8545fa6178297529cb833ab95c2d5d124e Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 28 Aug 2023 17:08:32 +0200 Subject: [PATCH 052/135] add new admin routes --- package.json | 2 + src/components/Header.tsx | 1 + src/layouts/AdminLayout.tsx | 90 +++++++++++++++++++ .../{AdminPage.tsx => AdminEventsPage.tsx} | 25 +----- src/pages/AdminStatsPage.tsx | 39 ++++++++ src/pages/AdminUsersPage.tsx | 39 ++++++++ src/routes/AdminRoutes.tsx | 36 ++++++++ src/routes/MainRoutes.tsx | 5 -- src/routes/index.ts | 4 +- 9 files changed, 214 insertions(+), 27 deletions(-) create mode 100644 src/layouts/AdminLayout.tsx rename src/pages/{AdminPage.tsx => AdminEventsPage.tsx} (66%) create mode 100644 src/pages/AdminStatsPage.tsx create mode 100644 src/pages/AdminUsersPage.tsx create mode 100644 src/routes/AdminRoutes.tsx diff --git a/package.json b/package.json index 1126de5..f697d9e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@mui/material": "^5.13.2", "@mui/x-data-grid": "^6.7.0", "@mui/x-date-pickers": "^6.8.0", + "apexcharts": "^3.42.0", "axios": "^1.4.0", "date-fns": "^2.30.0", "file-saver": "^2.0.5", @@ -23,6 +24,7 @@ "notistack": "^3.0.1", "qrcode.react": "^3.1.0", "react": "^18.2.0", + "react-apexcharts": "^1.4.1", "react-dom": "^18.2.0", "react-hook-form": "^7.45.0", "react-jwt": "^1.2.0", diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1447b65..bd4fa03 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -74,6 +74,7 @@ function Header() { bgcolor: "#c9e9ec", borderBottom: `1px solid #a8c3c5`, boxShadow: "none", + zIndex: (theme) => theme.zIndex.drawer + 1, }} color="inherit" position="fixed" diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx new file mode 100644 index 0000000..7a3992c --- /dev/null +++ b/src/layouts/AdminLayout.tsx @@ -0,0 +1,90 @@ +import { Outlet, NavLink } from "react-router-dom"; +import { useAtomValue } from "jotai"; + +import { + Box, + Container, + Drawer, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + Toolbar, +} from "@mui/material"; +import EventIcon from "@mui/icons-material/Event"; +import PeopleIcon from "@mui/icons-material/People"; +import QueryStatsIcon from "@mui/icons-material/QueryStats"; + +import { activeDialogAtom } from "states/atoms"; +import Header from "components/Header"; + +// TODO remove +const drawerWidth = 240; + +function AdminLayout(props: any) { + const activeDialog = useAtomValue(activeDialogAtom); + + const entries = [ + { label: "Reserves", icon: , to: "/admin/stats" }, + { label: "Users", icon: , to: "/admin/users" }, + { label: "Events", icon: , to: "/admin/events" }, + ]; + + return ( + +
    + + + + theme.palette.primary.dark, + }, + }, + }} + > + {entries.map((entry, index) => ( + + + {entry.icon} + + + + ))} + + + + + + + + + + ); +} + +export default AdminLayout; diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminEventsPage.tsx similarity index 66% rename from src/pages/AdminPage.tsx rename to src/pages/AdminEventsPage.tsx index bceafda..cddaaaa 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminEventsPage.tsx @@ -13,7 +13,7 @@ import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; -function AdminPage() { +function AdminEventsPage() { const { isActive, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); @@ -25,32 +25,15 @@ function AdminPage() { return isAuthenticated && permissions.includes("admin"); }, [isAuthenticated, permissions]); - const rows = React.useMemo(() => { - if (data) { - return data.map((event) => ({ - id: event.id, - status: event.status, - title: event.title, - dateStart: new Date(event.dateStart), - dateEnd: new Date(event.dateEnd), - slotsTaken: event.attendees?.length, - slotsTotal: event.tokenCount, - })); - } else { - return []; - } - }, [data]); - - // TODO add EventTable isAdmin return ( - + Events ); } -export default AdminPage; +export default AdminEventsPage; diff --git a/src/pages/AdminStatsPage.tsx b/src/pages/AdminStatsPage.tsx new file mode 100644 index 0000000..f4e7ec4 --- /dev/null +++ b/src/pages/AdminStatsPage.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import axios from "axios"; +import { useSnackbar } from "notistack"; +import { useAtom } from "jotai"; + +import { Box, Button } from "@mui/material"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { DialogIdentifier, Event } from "types"; +import { activeDialogAtom } from "states/atoms"; +import EventTable, { type EventTableRow } from "components/EventTable"; +import ContentWrapper from "components/ContentWrapper"; +import { useAuth } from "components/AuthContext"; + +function AdminStatsPage() { + const { isActive, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + const [slots, setSlots] = React.useState<{ used: number; max: number }>(); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("admin"); + }, [isAuthenticated, permissions]); + + return ( + + Stats + + ); +} + +export default AdminStatsPage; diff --git a/src/pages/AdminUsersPage.tsx b/src/pages/AdminUsersPage.tsx new file mode 100644 index 0000000..e98762e --- /dev/null +++ b/src/pages/AdminUsersPage.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import axios from "axios"; +import { useSnackbar } from "notistack"; +import { useAtom } from "jotai"; + +import { Box, Button } from "@mui/material"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { DialogIdentifier, Event } from "types"; +import { activeDialogAtom } from "states/atoms"; +import EventTable, { type EventTableRow } from "components/EventTable"; +import ContentWrapper from "components/ContentWrapper"; +import { useAuth } from "components/AuthContext"; + +function AdminUsersPage() { + const { isActive, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + const [slots, setSlots] = React.useState<{ used: number; max: number }>(); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("admin"); + }, [isAuthenticated, permissions]); + + return ( + + Users + + ); +} + +export default AdminUsersPage; diff --git a/src/routes/AdminRoutes.tsx b/src/routes/AdminRoutes.tsx new file mode 100644 index 0000000..41599d0 --- /dev/null +++ b/src/routes/AdminRoutes.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Navigate, type RouteObject } from "react-router-dom"; + +import Loadable from "components/Loadable"; +import AdminLayout from "layouts/AdminLayout"; + +const AdminEventsPage = Loadable(React.lazy(() => import("pages/AdminEventsPage"))); +const AdminUsersPage = Loadable(React.lazy(() => import("pages/AdminUsersPage"))); +const AdminStatsPage = Loadable(React.lazy(() => import("pages/AdminStatsPage"))); +const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); + +const AdminRoutes: RouteObject = { + path: "/", + element: , + errorElement: , + children: [ + { + path: "/admin", + element: , + }, + { + path: "/admin/stats", + element: , + }, + { + path: "/admin/users", + element: , + }, + { + path: "/admin/events", + element: , + }, + ], +}; + +export default AdminRoutes; diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index 7f71087..64c25ee 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -4,7 +4,6 @@ import type { RouteObject } from "react-router-dom"; import Loadable from "components/Loadable"; import MainLayout from "layouts/MainLayout"; -const AdminPage = Loadable(React.lazy(() => import("pages/AdminPage"))); const AttendeePage = Loadable(React.lazy(() => import("pages/AttendeePage"))); const DebugPage = Loadable(React.lazy(() => import("pages/DebugPage"))); const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); @@ -33,10 +32,6 @@ const MainRoutes: RouteObject = { path: "/attendee", element: , }, - { - path: "/admin", - element: , - }, { path: "/debug", element: , diff --git a/src/routes/index.ts b/src/routes/index.ts index 16cc03f..b416bb2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,8 +1,10 @@ import { useRoutes } from "react-router-dom"; +import AdminRoutes from "./AdminRoutes"; +import BasicRoutes from "./BasicRoutes"; import DefaultRoutes from "./DefaultRoutes"; import MainRoutes from "./MainRoutes"; export default function Routes() { - return useRoutes([MainRoutes, DefaultRoutes]); + return useRoutes([MainRoutes, AdminRoutes, BasicRoutes, DefaultRoutes]); } From a6d31c781e06f0b5530989a1701c70c080c6d51b Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 29 Aug 2023 10:41:17 +0200 Subject: [PATCH 053/135] rework providers to support new event minting flows --- package.json | 6 +- src/connectors/gem.ts | 122 ++++++++++++++++++++++++++----------- src/connectors/provider.ts | 7 ++- src/connectors/xumm.ts | 40 ++++++++++-- 4 files changed, 132 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index f697d9e..c3f3241 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@ant-design/icons": "^5.1.4", "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", - "@gemwallet/api": "^3.1.0", + "@gemwallet/api": "^3.4.0", "@hookform/resolvers": "^3.1.1", "@mui/icons-material": "^5.11.16", "@mui/lab": "^5.0.0-alpha.134", @@ -32,8 +32,8 @@ "react-scripts": "5.0.1", "react-window": "^1.8.9", "web-vitals": "^2.1.0", - "xrpl": "^2.7.0", - "xumm": "^1.5.4", + "xrpl": "^2.11.0", + "xumm": "^1.6.1", "zod": "^3.21.4", "zustand": "^4.3.8" }, diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index 58efe09..9fa958e 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -1,14 +1,6 @@ -import { - acceptNFTOffer, - getAddress, - getNetwork, - getPublicKey, - isInstalled, - Network, - signMessage, -} from "@gemwallet/api"; -import API from "apis"; +import * as Gem from "@gemwallet/api"; +import API from "apis"; import { Connector } from "connectors/connector"; import { AuthData, Provider } from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; @@ -33,25 +25,50 @@ export class GemWalletProvider extends Provider { this.authData = authData; } - public async signMessage(message: string): Promise { - const signed = await signMessage(message); - if (signed.type === "reject") { - throw Error("User refused to sign message"); + public async acceptOffer(id: string): Promise { + const response = await Gem.acceptNFTOffer({ + NFTokenSellOffer: id, + }); + if (response.type === "reject") { + throw Error("User refused to sign NFTokenAcceptOffer transaction"); } - if (!signed.result) { - throw Error("Failed to sign message"); + return Boolean(response.result?.hash); + } + + public async setAccount(minterAddress: string): Promise { + const response = await Gem.setAccount({ + NFTokenMinter: minterAddress, + }); + if (response.type === "reject") { + throw Error("User refused to sign AccountSet transaction"); } - return signed.result.signedMessage; + return Boolean(response.result?.hash); } - public async acceptOffer(id: string): Promise { - const response = await acceptNFTOffer({ - NFTokenSellOffer: id, + public async sendPayment( + amount: string, + destination: string, + memo?: string + ): Promise { + const response = await Gem.sendPayment({ + amount: amount, + destination: destination, + memos: memo + ? [ + { + memo: { + memoData: Buffer.from(memo, "utf8") + .toString("hex") + .toUpperCase(), + }, + }, + ] + : [], }); if (response.type === "reject") { - throw Error("User refused to sign transaction"); + throw Error("User refused to sign Payment transaction"); } return Boolean(response.result?.hash); @@ -73,13 +90,16 @@ export class GemWallet extends Connector { public provider: GemWalletProvider | undefined; private readonly options: GemWalletOptions; + private wallet: boolean; constructor({ options, onError }: GemWalletConstructorArgs) { super(onError); + this.provider = undefined; this.options = options; + this.wallet = false; } - private mapNetworkId(network: Network): NetworkIdentifier { + private mapNetworkId(network: Gem.Network): NetworkIdentifier { switch (network.toString().toLowerCase()) { case "mainnet": return NetworkIdentifier.MAINNET; @@ -98,27 +118,41 @@ export class GemWallet extends Connector { return ConnectorType.GEM; } - public async activate(): Promise { + private async init(): Promise { + Gem.on("logout", async (event) => { + await this.deactivate(); + }); + + Gem.on("networkChanged", (event) => { + this.state.update({ + networkId: this.mapNetworkId(event.network.name), + }); + }); + + Gem.on("walletChanged", async (event) => { + console.log("walletChanged", event.wallet.publicAddress); + await this.deactivate(); + await this.initProvider(); + }); + + this.wallet = true; + } + + private async initProvider(): Promise { const cancelActivation = this.state.startActivation(); - this.provider = undefined; try { - const installed = await isInstalled(); - if (!installed.result.isInstalled) { - throw new NoGemWalletError(); - } - - const address = await getAddress(); + const address = await Gem.getAddress(); if (address.type === "reject" || !address.result) { - throw Error("User refused to share GemWallet address"); + throw Error("User refused to share wallet address"); } - const network = await getNetwork(); + const network = await Gem.getNetwork(); if (network.type === "reject" || !network.result) { throw Error("User refused to share network"); } - const pubkey = await getPublicKey(); + const pubkey = await Gem.getPublicKey(); if (pubkey.type === "reject" || !pubkey.result) { throw Error("User refused to share public key"); } @@ -127,7 +161,9 @@ export class GemWallet extends Connector { pubkey: pubkey.result.publicKey, }); - const signed = await signMessage(`backend authentication: ${tempJwt}`); + const signed = await Gem.signMessage( + `backend authentication: ${tempJwt}` + ); if (signed.type === "reject" || !signed.result) { throw Error("User refused to sign auth message"); } @@ -148,6 +184,24 @@ export class GemWallet extends Connector { } } + public async activate(): Promise { + const installed = await Gem.isInstalled(); + if (!installed.result.isInstalled) { + throw new NoGemWalletError(); + } + + if (!this.wallet) { + await this.init(); + } + + // only do something, if not already connected + if (this.provider) { + return; + } + + await this.initProvider(); + } + public async deactivate(): Promise { this.provider = undefined; this.state.reset(); diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts index a66a3f9..2499120 100644 --- a/src/connectors/provider.ts +++ b/src/connectors/provider.ts @@ -3,7 +3,12 @@ export type AuthData = { }; export abstract class Provider { - public abstract signMessage(message: string): Promise | string; public abstract acceptOffer(offerIndex: string): Promise | boolean; + public abstract setAccount(minterAddress: string): Promise | boolean; + public abstract sendPayment( + amount: string, + destination: string, + memo?: string + ): Promise | boolean; public abstract getAuthData(): Promise | AuthData; } diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index a5b6afe..16d9e63 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -32,11 +32,6 @@ export class XummWalletProvider extends Provider { return payload.resolved; } - public async signMessage(message: string): Promise { - // TODO not yet supported - return ""; - } - public async acceptOffer(id: string): Promise { const result = await this.submitPayload({ TransactionType: "NFTokenAcceptOffer", @@ -46,6 +41,40 @@ export class XummWalletProvider extends Provider { return Boolean(result); } + public async setAccount(minterAddress: string): Promise { + const result = await this.submitPayload({ + TransactionType: "AccountSet", + NFTokenMinter: minterAddress, + }); + + return Boolean(result); + } + + public async sendPayment( + amount: string, + destination: string, + memo?: string + ): Promise { + const result = await this.submitPayload({ + TransactionType: "Payment", + Amount: amount, + Destination: destination, + Memos: memo + ? [ + { + Memo: { + MemoData: Buffer.from(memo, "utf8") + .toString("hex") + .toUpperCase(), + }, + }, + ] + : [], + }); + + return Boolean(result); + } + public getAuthData(): AuthData { return { tempJwt: this.jwt, @@ -70,6 +99,7 @@ export class XummWallet extends Connector { constructor({ apiKey, options, onError }: XummWalletConstructorArgs) { super(onError); + this.provider = undefined; this.apiKey = apiKey; this.options = options; } From a06f9285daf144477527db6dc704529bcc92674e Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 29 Aug 2023 12:47:03 +0200 Subject: [PATCH 054/135] add admin stats page template --- src/components/PieChart.tsx | 42 +++++++++ src/layouts/AdminLayout.tsx | 17 +++- src/pages/AdminStatsPage.tsx | 175 +++++++++++++++++++++++++++++++++-- 3 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 src/components/PieChart.tsx diff --git a/src/components/PieChart.tsx b/src/components/PieChart.tsx new file mode 100644 index 0000000..d9c4346 --- /dev/null +++ b/src/components/PieChart.tsx @@ -0,0 +1,42 @@ +import { ApexOptions } from "apexcharts"; +import ReactApexChart from "react-apexcharts"; + +type PieChartProps = { + height?: string | number; + series: number[]; + labels: string[]; +}; + +function PieChart({ height, series, labels }: PieChartProps) { + const options: ApexOptions = { + chart: { + height: height, + type: "pie", + }, + labels: labels, + responsive: [ + { + breakpoint: 480, + options: { + chart: { + width: 200, + }, + legend: { + position: "bottom", + }, + }, + }, + ], + }; + + return ( + + ); +} + +export default PieChart; diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 7a3992c..508d283 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -11,6 +11,7 @@ import { ListItemIcon, ListItemText, Toolbar, + useTheme, } from "@mui/material"; import EventIcon from "@mui/icons-material/Event"; import PeopleIcon from "@mui/icons-material/People"; @@ -22,11 +23,12 @@ import Header from "components/Header"; // TODO remove const drawerWidth = 240; -function AdminLayout(props: any) { +function AdminLayout() { + const theme = useTheme(); const activeDialog = useAtomValue(activeDialogAtom); const entries = [ - { label: "Reserves", icon: , to: "/admin/stats" }, + { label: "Overview", icon: , to: "/admin/stats" }, { label: "Users", icon: , to: "/admin/users" }, { label: "Events", icon: , to: "/admin/events" }, ]; @@ -71,14 +73,21 @@ function AdminLayout(props: any) { - + diff --git a/src/pages/AdminStatsPage.tsx b/src/pages/AdminStatsPage.tsx index f4e7ec4..8a27f64 100644 --- a/src/pages/AdminStatsPage.tsx +++ b/src/pages/AdminStatsPage.tsx @@ -1,9 +1,16 @@ import React from "react"; -import axios from "axios"; import { useSnackbar } from "notistack"; import { useAtom } from "jotai"; -import { Box, Button } from "@mui/material"; +import { + Box, + Button, + Grid, + Typography, + Card, + CardContent, + CardActions, +} from "@mui/material"; import API from "apis"; import { useWeb3 } from "connectors/context"; @@ -12,6 +19,7 @@ import { activeDialogAtom } from "states/atoms"; import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; +import PieChart from "components/PieChart"; function AdminStatsPage() { const { isActive, networkId } = useWeb3(); @@ -26,13 +34,162 @@ function AdminStatsPage() { }, [isAuthenticated, permissions]); return ( - - Stats - + + + Platform Stats + + + + + + Total Users + + + asdf + + + adjective + + + well meaning and kindly. +
    + {'"a benevolent smile"'} +
    +
    + + + +
    +
    + + + + + Total Events + + + asdf + + + adjective + + + well meaning and kindly. +
    + {'"a benevolent smile"'} +
    +
    + + + +
    +
    + + + + + Total Balance + + + asdf + + + adjective + + + well meaning and kindly. +
    + {'"a benevolent smile"'} +
    +
    + + + +
    +
    + + + + + Total Reserves + + + asdf + + + adjective + + + well meaning and kindly. +
    + {'"a benevolent smile"'} +
    +
    + + + +
    +
    + + + + + + + Account Balance + + + + + + + + + + + + + + + Account Reserves + + + + + + + + + + + + ); } From 51e6bfb6051594c104804fdcadd12b6c1b626aff Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 4 Sep 2023 08:40:19 +0200 Subject: [PATCH 055/135] [tmp] claim workflow --- src/apis/event.ts | 8 +- src/components/ClaimDialog.tsx | 9 +- src/components/JoinDialog.tsx | 2 +- src/config.ts | 28 ++- src/connectors/gem.ts | 20 +- src/connectors/provider.ts | 12 +- src/connectors/xumm.ts | 52 +++-- src/layouts/BasicLayout.tsx | 25 +++ src/pages/ClaimPage.tsx | 344 +++++++++++++++++++++++++++++++++ src/routes/BasicRoutes.tsx | 22 +++ 10 files changed, 486 insertions(+), 36 deletions(-) create mode 100644 src/layouts/BasicLayout.tsx create mode 100644 src/pages/ClaimPage.tsx create mode 100644 src/routes/BasicRoutes.tsx diff --git a/src/apis/event.ts b/src/apis/event.ts index 93b8d8f..bd8a1cf 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -64,7 +64,7 @@ export const create = async ( }; export type joinData = { - eventId: number; + maskedEventId: string; createOffer: boolean; }; @@ -85,10 +85,10 @@ export const join = async (jwt: string, data: joinData): Promise => { }; export type claimData = { - eventId: number; + maskedEventId: string; }; -export const claim = async (jwt: string, data: claimData): Promise => { +export const claim = async (jwt: string, data: claimData): Promise => { const response = await axios.post( new URL("/event/claim", config.apiURL).toString(), data, @@ -101,7 +101,7 @@ export const claim = async (jwt: string, data: claimData): Promise => { } ); - return response.data.result as Offer; + return response.data.result as Offer | null; }; export type inviteData = { diff --git a/src/components/ClaimDialog.tsx b/src/components/ClaimDialog.tsx index 7afea30..f826902 100644 --- a/src/components/ClaimDialog.tsx +++ b/src/components/ClaimDialog.tsx @@ -53,10 +53,17 @@ function ClaimDialog() { try { if (provider && account && data?.eventId && jwt) { const offer = await API.event.claim(jwt, { - eventId: data.eventId, + maskedEventId: data.eventId, }); console.debug("ClaimResult", offer); + if(!offer) { + enqueueSnackbar(`Claim failed: User is not a participant`, { + variant: "error", + }); + return; + } + if (offer.offerIndex && !offer.claimed) { enqueueSnackbar( "Creating NFT claim request (confirm the transaction in your wallet)", diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index eb0314b..7da21f3 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -58,7 +58,7 @@ function JoinDialog() { try { if (provider && account && data?.eventId && jwt) { const offer = await API.event.join(jwt, { - eventId: data.eventId, + maskedEventId: data.eventId, createOffer: checked, }); console.debug("JoinResult", offer); diff --git a/src/config.ts b/src/config.ts index 68a6a0d..7fae0e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,20 +1,36 @@ -const DEFAULT = { +type Config = { + debug: boolean; + apiURL: string; + timeout: number; + storage: Storage; + connector: { + xumm: { + apiKey: string; + options: { + redirectUrl?: string; + rememberJwt?: boolean; + storage?: Storage; + implicit?: boolean; + }; + }; + }; +}; + +const DEFAULT: Config = { + debug: true, apiURL: process.env.REACT_APP_URL_POAP_API as string, timeout: 5000, - storage: window.sessionStorage, - connector: { xumm: { apiKey: process.env.REACT_APP_KEY_XUMM_API as string, options: { - implicit: true, + rememberJwt: true, storage: window.sessionStorage, + implicit: true, }, }, }, - - debug: false, }; const config = DEFAULT; diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index 9fa958e..f1e6513 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -2,7 +2,7 @@ import * as Gem from "@gemwallet/api"; import API from "apis"; import { Connector } from "connectors/connector"; -import { AuthData, Provider } from "connectors/provider"; +import { AuthData, Provider, type ProviderRequestResult } from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; export class NoGemWalletError extends Error { @@ -25,7 +25,7 @@ export class GemWalletProvider extends Provider { this.authData = authData; } - public async acceptOffer(id: string): Promise { + public async acceptOffer(id: string): Promise { const response = await Gem.acceptNFTOffer({ NFTokenSellOffer: id, }); @@ -33,10 +33,12 @@ export class GemWalletProvider extends Provider { throw Error("User refused to sign NFTokenAcceptOffer transaction"); } - return Boolean(response.result?.hash); + return { + resolved: Promise.resolve(Boolean(response.result?.hash)), + }; } - public async setAccount(minterAddress: string): Promise { + public async setAccount(minterAddress: string): Promise { const response = await Gem.setAccount({ NFTokenMinter: minterAddress, }); @@ -44,14 +46,16 @@ export class GemWalletProvider extends Provider { throw Error("User refused to sign AccountSet transaction"); } - return Boolean(response.result?.hash); + return { + resolved: Promise.resolve(Boolean(response.result?.hash)), + }; } public async sendPayment( amount: string, destination: string, memo?: string - ): Promise { + ): Promise { const response = await Gem.sendPayment({ amount: amount, destination: destination, @@ -71,7 +75,9 @@ export class GemWalletProvider extends Provider { throw Error("User refused to sign Payment transaction"); } - return Boolean(response.result?.hash); + return { + resolved: Promise.resolve(Boolean(response.result?.hash)), + }; } public getAuthData(): GemAuthData { diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts index 2499120..b357d15 100644 --- a/src/connectors/provider.ts +++ b/src/connectors/provider.ts @@ -2,13 +2,19 @@ export type AuthData = { tempJwt: string; }; +export type ProviderRequestResult = { + resolved: Promise; + uuid?: string; + payload?: any; +} + export abstract class Provider { - public abstract acceptOffer(offerIndex: string): Promise | boolean; - public abstract setAccount(minterAddress: string): Promise | boolean; + public abstract acceptOffer(offerIndex: string): Promise; + public abstract setAccount(minterAddress: string): Promise; public abstract sendPayment( amount: string, destination: string, memo?: string - ): Promise | boolean; + ): Promise; public abstract getAuthData(): Promise | AuthData; } diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 16d9e63..429a84d 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -3,9 +3,14 @@ import { XummPkce } from "xumm-oauth2-pkce"; import config from "config"; import { Connector } from "connectors/connector"; -import { AuthData, Provider } from "connectors/provider"; +import { AuthData, Provider, type ProviderRequestResult } from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; +async function _wrap(promise?: Promise): Promise { + const result = await promise; + return !!result; +} + export class XummWalletProvider extends Provider { private sdk: XummSdkJwt; private jwt: string; @@ -18,7 +23,9 @@ export class XummWalletProvider extends Provider { this.pendingPayloads = []; // TODO make this session persistent (re-subscribe in case user refreshes page) } - private async submitPayload(tx: SdkTypes.XummJsonTransaction): Promise { + private async submitPayload( + tx: SdkTypes.XummJsonTransaction + ): Promise { const callback = async (event: SdkTypes.SubscriptionCallbackParams) => { console.debug("callback", event); if (event.data?.payload_uuidv4) { @@ -27,35 +34,49 @@ export class XummWalletProvider extends Provider { } }; - const payload = await this.sdk.payload.createAndSubscribe(tx, callback); - this.pendingPayloads.push(payload.created.uuid); - return payload.resolved; + const subscription = await this.sdk.payload.createAndSubscribe( + { + txjson: tx, + options: {}, + }, + callback + ); + + this.pendingPayloads.push(subscription.created.uuid); + console.log("sub", subscription); + return subscription; } - public async acceptOffer(id: string): Promise { - const result = await this.submitPayload({ + public async acceptOffer(id: string): Promise { + const subscription = await this.submitPayload({ TransactionType: "NFTokenAcceptOffer", NFTokenSellOffer: id, }); - return Boolean(result); + return { + resolved: _wrap(subscription.resolved), + uuid: subscription.created.uuid, + }; } - public async setAccount(minterAddress: string): Promise { - const result = await this.submitPayload({ + public async setAccount(minterAddress: string): Promise { + const subscription = await this.submitPayload({ TransactionType: "AccountSet", NFTokenMinter: minterAddress, }); - return Boolean(result); + return { + resolved: _wrap(subscription.resolved), + uuid: subscription.created.uuid, + }; } public async sendPayment( amount: string, destination: string, memo?: string - ): Promise { - const result = await this.submitPayload({ + ): Promise { + const subscription = await this.submitPayload({ TransactionType: "Payment", Amount: amount, Destination: destination, @@ -72,7 +93,10 @@ export class XummWalletProvider extends Provider { : [], }); - return Boolean(result); + return { + resolved: _wrap(subscription.resolved), + uuid: subscription.created.uuid, + }; } public getAuthData(): AuthData { diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx new file mode 100644 index 0000000..43675e2 --- /dev/null +++ b/src/layouts/BasicLayout.tsx @@ -0,0 +1,25 @@ +import { Outlet } from "react-router-dom"; + +import Box from "@mui/material/Box"; +import Container from "@mui/material/Container"; + +function BasicLayout(props: any) { + return ( + + + + + + + + ); +} + +export default BasicLayout; diff --git a/src/pages/ClaimPage.tsx b/src/pages/ClaimPage.tsx new file mode 100644 index 0000000..fa4b588 --- /dev/null +++ b/src/pages/ClaimPage.tsx @@ -0,0 +1,344 @@ +import React from "react"; +import { useAtom } from "jotai"; +import { useParams, useNavigate } from "react-router-dom"; +import axios from "axios"; +import { useAtomValue } from "jotai"; +import { useSnackbar } from "notistack"; + +import { + Button, + MobileStepper, + Stepper, + Step, + StepLabel, + Box, + Typography, +} from "@mui/material"; +import CircularProgress from "@mui/material/CircularProgress"; + +import { Offer } from "types"; +import { useAuth } from "components/AuthContext"; +import { useWeb3 } from "connectors/context"; +import API from "apis"; +import ContentWrapper from "components/ContentWrapper"; + +import { selectedWalletAtom } from "states/atoms"; +import { shortenAddress } from "utils/strings"; +import { xumm } from "connectors/xumm"; +import { getConnector } from "connectors"; +import type { Connector } from "connectors/connector"; +import { ConnectorType } from "types"; + +const steps = ["Connect Wallet", "Claim NFT", "Finished"]; + +function ClaimPage() { + const { connector, provider, account, isActive } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); + const [data, setData] = React.useState(); + const [uuid, setUuid] = React.useState(); + const [loading, setLoading] = React.useState(false); + const [activeStep, setActiveStep] = React.useState(); + const { enqueueSnackbar } = useSnackbar(); + + const { id } = useParams(); + + // eagerly connect (refresh) + React.useEffect(() => { + let selectedConnector: Connector | undefined; + if (selectedWallet) { + try { + selectedConnector = getConnector(selectedWallet as ConnectorType); + } catch { + setSelectedWallet(ConnectorType.EMPTY); + } + } + + if (selectedConnector && !selectedConnector.state.isActive()) { + if (selectedConnector.getType() === ConnectorType.XUMM) { + selectedConnector.activate(); + } + } + }, [selectedWallet, setSelectedWallet]); + + // eagerly connect (deeplink redirect) + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + await xumm.activate(); + if (mounted) { + setSelectedWallet(ConnectorType.XUMM); + } + } catch (err) { + enqueueSnackbar( + `Failed to connect wallet (redirect): ${(err as Error).message}`, + { + variant: "error", + } + ); + } + }; + + const params = new URLSearchParams(document?.location?.search || ""); + if (params.get("access_token")) { + load(); + } + + return () => { + mounted = false; + }; + }, []); + + // fetch claim offer + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (jwt && id) { + const offer = await API.event.claim(jwt, { + maskedEventId: id, + }); + + if (mounted) { + setData(offer); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load offer data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load offer data", { + variant: "error", + }); + } + } + }; + + if (isAuthenticated) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [isAuthenticated, jwt]); + + const handleConnect = React.useCallback(async () => { + // disconnect, if not Xumm + if (isActive && connector?.getType() !== ConnectorType.XUMM) { + try { + if (connector?.deactivate) { + await connector.deactivate(); + } else { + await connector?.reset(); + } + setSelectedWallet(ConnectorType.EMPTY); + } catch (err) { + enqueueSnackbar( + `Failed to disconnect wallet: ${(err as Error).message}`, + { variant: "error" } + ); + return; + } + } + + setLoading(true); + try { + await xumm.activate(); + setSelectedWallet(ConnectorType.XUMM); + } catch (err) { + enqueueSnackbar(`Failed to connect wallet: ${(err as Error).message}`, { + variant: "error", + }); + } finally { + setLoading(false); + } + }, [connector, isActive, setSelectedWallet]); + + const handleClaim = async (event: React.MouseEvent) => { + setLoading(true); + try { + if (provider && account && id && jwt) { + if (data === null) { + const offer = await API.event.join(jwt, { + maskedEventId: id, + createOffer: true, + }); + console.debug("JoinResult", offer); + enqueueSnackbar(`Sign-up successful: Event #${id}`, { + variant: "success", + }); + setData(offer); + } + + if (data?.offerIndex && !data?.claimed) { + enqueueSnackbar( + "Creating NFT claim request (confirm the transaction in your wallet)", + { + variant: "warning", + autoHideDuration: 30000, + } + ); + const result = await provider.acceptOffer(data.offerIndex); + + setUuid(result.uuid); + + const success = await result.resolved; + + if (success) { + enqueueSnackbar("Claim successful", { + variant: "success", + }); + // TODO trigger accepted to reload claim info + } else { + enqueueSnackbar(`Claim failed: Unable to claim NFT`, { + variant: "error", + }); + } + } + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + // setData(undefined); + // setActiveDialog({}); + } + }; + + // const handleClaim = async (event: React.MouseEvent) => { + // // setLoading(true); + // try { + // if (provider && account && id && jwt) { + // const offer = await API.event.claim(jwt, { + // maskedEventId: id, + // }); + // console.debug("ClaimResult", offer); + + // if (offer && offer.offerIndex && !offer.claimed) { + // enqueueSnackbar( + // "Creating NFT claim request (confirm the transaction in your wallet)", + // { + // variant: "warning", + // autoHideDuration: 30000, + // } + // ); + // const success = await provider.acceptOffer(offer.offerIndex); + + // if (success) { + // enqueueSnackbar("Claim successful", { + // variant: "success", + // }); + // } else { + // enqueueSnackbar(`Claim failed: Unable to claim NFT`, { + // variant: "error", + // }); + // } + // } else { + // enqueueSnackbar(`Claim successful: Already claimed NFT`, { + // variant: "success", + // }); + // } + // } + // } catch (err) { + // console.debug(err); + // if (axios.isAxiosError(err)) { + // enqueueSnackbar(`Claim failed: ${err.response?.data.error}`, { + // variant: "error", + // }); + // } else { + // enqueueSnackbar(`Claim failed: ${(err as Error).message}`, { + // variant: "error", + // }); + // } + // } finally { + // // setLoading(false); + // // setData(undefined); + // } + // }; + + return ( + + + event id: {id} + + + + connected: {isActive ? "true" : "false"} + {/*

    {document.location.href}

    */} +
    + + + + uuid: {uuid} + {uuid && ( +

    + + deeplink + +

    + )} + offer: {data?.offerIndex?.substring(0, 4)} + claimed: {data?.claimed ? "true" : "false"} + + + + {steps.map((label) => ( + + {label} + + ))} + + + + ); +} + +export default ClaimPage; diff --git a/src/routes/BasicRoutes.tsx b/src/routes/BasicRoutes.tsx new file mode 100644 index 0000000..3faa9cd --- /dev/null +++ b/src/routes/BasicRoutes.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import type { RouteObject } from "react-router-dom"; + +import Loadable from "components/Loadable"; +import BasicLayout from "layouts/BasicLayout"; + +const ClaimPage = Loadable(React.lazy(() => import("pages/ClaimPage"))); +const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); + +const MainRoutes: RouteObject = { + path: "/", + element: , + errorElement: , + children: [ + { + path: "/claim/:id", + element: , + }, + ], +}; + +export default MainRoutes; From d1d067392171ff3a6324da70248ede95681003ef Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 4 Sep 2023 21:41:15 +0200 Subject: [PATCH 056/135] [tmp] claim workflow 2 --- package.json | 1 + src/components/Debug.tsx | 66 ++++++++ src/connectors/provider.ts | 1 - src/connectors/xumm.ts | 17 +- src/pages/ClaimPage.tsx | 316 +++++++++++++++++++++++++------------ src/types.ts | 1 + 6 files changed, 295 insertions(+), 107 deletions(-) create mode 100644 src/components/Debug.tsx diff --git a/package.json b/package.json index c3f3241..95a99f4 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-apexcharts": "^1.4.1", + "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-hook-form": "^7.45.0", "react-jwt": "^1.2.0", diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx new file mode 100644 index 0000000..07fb444 --- /dev/null +++ b/src/components/Debug.tsx @@ -0,0 +1,66 @@ +import React from "react"; + +import Box from "@mui/system/Box"; + +import config from "config"; + +function replacer(name: any, val: any) { + if (val && val.type === "Buffer") { + return "buffer"; + } + return val; +} + +type DebugWrapperProps = { + enabled: boolean; + children?: React.ReactNode; +}; + +function DebugWrapper(props: DebugWrapperProps) { + const { enabled, children } = props; + + return enabled ? ( + + {children} + + ) : null; +} + +type DebugProps = { + value: any; + children?: React.ReactNode; +}; + +export function DebugRenderer(props: DebugProps) { + const { value, children } = props; + const target = value || children; + + const data = React.useMemo(() => { + try { + const str = JSON.stringify(target, replacer, 2); + return str; + } catch (err) { + return `Failed to stringify: ${err}`; + } + }, [target]); + + return ( + + {data} + + ); +} + +export function Debug(props: DebugProps) { + const { value, children } = props; + return ( + + + + ); +} diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts index b357d15..162e852 100644 --- a/src/connectors/provider.ts +++ b/src/connectors/provider.ts @@ -5,7 +5,6 @@ export type AuthData = { export type ProviderRequestResult = { resolved: Promise; uuid?: string; - payload?: any; } export abstract class Provider { diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 429a84d..03e055e 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -3,7 +3,11 @@ import { XummPkce } from "xumm-oauth2-pkce"; import config from "config"; import { Connector } from "connectors/connector"; -import { AuthData, Provider, type ProviderRequestResult } from "connectors/provider"; +import { + AuthData, + Provider, + type ProviderRequestResult, +} from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; async function _wrap(promise?: Promise): Promise { @@ -37,7 +41,12 @@ export class XummWalletProvider extends Provider { const subscription = await this.sdk.payload.createAndSubscribe( { txjson: tx, - options: {}, + options: { + return_url: { + app: document.location.href, + web: undefined, + }, + }, }, callback ); @@ -59,7 +68,9 @@ export class XummWalletProvider extends Provider { }; } - public async setAccount(minterAddress: string): Promise { + public async setAccount( + minterAddress: string + ): Promise { const subscription = await this.submitPayload({ TransactionType: "AccountSet", NFTokenMinter: minterAddress, diff --git a/src/pages/ClaimPage.tsx b/src/pages/ClaimPage.tsx index fa4b588..7487add 100644 --- a/src/pages/ClaimPage.tsx +++ b/src/pages/ClaimPage.tsx @@ -4,14 +4,17 @@ import { useParams, useNavigate } from "react-router-dom"; import axios from "axios"; import { useAtomValue } from "jotai"; import { useSnackbar } from "notistack"; +import { isMobile } from "react-device-detect"; import { + Alert, + Box, Button, + Collapse, MobileStepper, - Stepper, Step, StepLabel, - Box, + Stepper, Typography, } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; @@ -28,21 +31,70 @@ import { xumm } from "connectors/xumm"; import { getConnector } from "connectors"; import type { Connector } from "connectors/connector"; import { ConnectorType } from "types"; +import { Debug } from "components/Debug"; + +// TODO https://m1.material.io/components/steppers.html#steppers-types-of-steps + +// TODO remove enqueueSnackbar, use stepper to display errors/problems + +type StepState = { + name: string; + label: string; + completed: boolean; + error?: string; +}; + +type State = { + activeStep: string; + errors: string[]; + completed: boolean[]; +}; const steps = ["Connect Wallet", "Claim NFT", "Finished"]; +type stepIds = "connect" | "claim" | "finish"; + +type IStep = { + id: stepIds; + label: string; + node?: React.ReactNode; +}; + +const steps2: IStep[] = [ + { + id: "connect", + label: "Connect Wallet", + }, + { id: "claim", label: "Claim NFT" }, + { id: "finish", label: "Finished" }, +]; + +type IView = {}; + +// errors[id] = string | undefined + function ClaimPage() { const { connector, provider, account, isActive } = useWeb3(); - const { isAuthenticated, jwt, permissions } = useAuth(); + const { isAuthenticated, jwt, permissions, isAuto, toggleAuto } = useAuth(); const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); const [data, setData] = React.useState(); const [uuid, setUuid] = React.useState(); + const [errors, setErrors] = React.useState(steps.map(() => "")); + const [completed, setCompleted] = React.useState(); + const [count, setCount] = React.useState(0); const [loading, setLoading] = React.useState(false); - const [activeStep, setActiveStep] = React.useState(); + const [activeStep, setActiveStep] = React.useState(0); const { enqueueSnackbar } = useSnackbar(); const { id } = useParams(); + // force enable auto login + React.useEffect(() => { + if (!isAuto) { + toggleAuto(); + } + }, [isAuto, toggleAuto]); + // eagerly connect (refresh) React.useEffect(() => { let selectedConnector: Connector | undefined; @@ -72,6 +124,10 @@ function ClaimPage() { setSelectedWallet(ConnectorType.XUMM); } } catch (err) { + // TODO step 0 error + // if (mounted) { + // setErrors() + // } enqueueSnackbar( `Failed to connect wallet (redirect): ${(err as Error).message}`, { @@ -111,6 +167,7 @@ function ClaimPage() { if (mounted) { setData(undefined); } + // TODO step 1: erorr if (axios.isAxiosError(err)) { enqueueSnackbar( `Failed to load offer data: ${err.response?.data.error}`, @@ -135,7 +192,39 @@ function ClaimPage() { return () => { mounted = false; }; - }, [isAuthenticated, jwt]); + }, [isAuthenticated, jwt, id, count]); + + // TODO update step + // TODO update completed, error ? + React.useEffect(() => { + // setCompleted(step0: isActive) + + // TODO need to check isAuthenticated somewhere and display error if needed + + if (!isActive || !isAuthenticated) { + // not connected + setActiveStep(0); + } else if (!data?.claimed) { + // nft not claimed + setActiveStep(1); + } else { + setActiveStep(2); + } + }, [isActive, isAuthenticated, data]); + + // const findCurrentStep = React.useCallback(async () => { + // // connected ? + // if (!isActive) { + // return 1; + // } + + // // claimed ? + // if (!data?.claimed) { + // return 2; + // } + + // return 3; + // }, [isActive]); const handleConnect = React.useCallback(async () => { // disconnect, if not Xumm @@ -169,12 +258,16 @@ function ClaimPage() { } }, [connector, isActive, setSelectedWallet]); + // TODO make useCallback const handleClaim = async (event: React.MouseEvent) => { setLoading(true); try { if (provider && account && id && jwt) { - if (data === null) { - const offer = await API.event.join(jwt, { + let offer = data; + + // join event + if (offer === null) { + offer = await API.event.join(jwt, { maskedEventId: id, createOffer: true, }); @@ -185,7 +278,8 @@ function ClaimPage() { setData(offer); } - if (data?.offerIndex && !data?.claimed) { + // claim nft + if (offer?.offerIndex && !offer?.claimed) { enqueueSnackbar( "Creating NFT claim request (confirm the transaction in your wallet)", { @@ -193,7 +287,7 @@ function ClaimPage() { autoHideDuration: 30000, } ); - const result = await provider.acceptOffer(data.offerIndex); + const result = await provider.acceptOffer(offer.offerIndex); setUuid(result.uuid); @@ -204,10 +298,17 @@ function ClaimPage() { variant: "success", }); // TODO trigger accepted to reload claim info + + // force reload claim info + setCount(count + 1); // TODO use reducer? + // bad + // offer.claimed = true; + // setData(offer); } else { enqueueSnackbar(`Claim failed: Unable to claim NFT`, { variant: "error", }); + setUuid(undefined); } } } @@ -223,107 +324,116 @@ function ClaimPage() { }); } } finally { + console.log("Finally being called"); setLoading(false); // setData(undefined); // setActiveDialog({}); } }; - // const handleClaim = async (event: React.MouseEvent) => { - // // setLoading(true); - // try { - // if (provider && account && id && jwt) { - // const offer = await API.event.claim(jwt, { - // maskedEventId: id, - // }); - // console.debug("ClaimResult", offer); - - // if (offer && offer.offerIndex && !offer.claimed) { - // enqueueSnackbar( - // "Creating NFT claim request (confirm the transaction in your wallet)", - // { - // variant: "warning", - // autoHideDuration: 30000, - // } - // ); - // const success = await provider.acceptOffer(offer.offerIndex); - - // if (success) { - // enqueueSnackbar("Claim successful", { - // variant: "success", - // }); - // } else { - // enqueueSnackbar(`Claim failed: Unable to claim NFT`, { - // variant: "error", - // }); - // } - // } else { - // enqueueSnackbar(`Claim successful: Already claimed NFT`, { - // variant: "success", - // }); - // } - // } - // } catch (err) { - // console.debug(err); - // if (axios.isAxiosError(err)) { - // enqueueSnackbar(`Claim failed: ${err.response?.data.error}`, { - // variant: "error", - // }); - // } else { - // enqueueSnackbar(`Claim failed: ${(err as Error).message}`, { - // variant: "error", - // }); - // } - // } finally { - // // setLoading(false); - // // setData(undefined); - // } - // }; - return ( - - - event id: {id} - - - - connected: {isActive ? "true" : "false"} - {/*

    {document.location.href}

    */} -
    - - - - uuid: {uuid} - {uuid && ( -

    - - deeplink - -

    - )} - offer: {data?.offerIndex?.substring(0, 4)} - claimed: {data?.claimed ? "true" : "false"} -
    + + + errors[activeStep] + + + {(() => { + switch (activeStep) { + case 0: + return ( + + + + ); + case 1: + return ( + + + + {isMobile && uuid && ( + + + + )} + + ); + case 2: + return ( + + Successfully claimed NFT! + + ); + default: + return null; + } + })()} + + + Date: Mon, 4 Sep 2023 21:45:21 +0200 Subject: [PATCH 057/135] rework /events API --- src/apis/events.ts | 13 +++++-- src/pages/HomePage.tsx | 76 +++---------------------------------- src/pages/OrganizerPage.tsx | 1 - src/types.ts | 12 ++++++ 4 files changed, 26 insertions(+), 76 deletions(-) diff --git a/src/apis/events.ts b/src/apis/events.ts index 0390163..9466e8b 100644 --- a/src/apis/events.ts +++ b/src/apis/events.ts @@ -4,15 +4,21 @@ import config from "config"; import type { Event } from "types"; import { NetworkIdentifier } from "types"; -export type getPublicParams = { +export type getAllParams = { networkId: NetworkIdentifier; limit?: number; }; -export const getPublic = async (params: getPublicParams): Promise => { +export const getAll = async ( + jwt: string, + params: getAllParams +): Promise => { const response = await axios.get( - new URL("/events/public", config.apiURL).toString(), + new URL("/events/all", config.apiURL).toString(), { + headers: { + Authorization: `Bearer ${jwt}`, + }, responseType: "json", timeout: config.timeout, params: params, @@ -25,7 +31,6 @@ export const getPublic = async (params: getPublicParams): Promise => { export type getOwnedParams = { networkId: NetworkIdentifier; limit?: number; - includeAttendees?: boolean; }; export const getOwned = async ( diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 9172ade..ce1edf1 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,22 +1,12 @@ import React from "react"; -import axios from "axios"; -import { useSnackbar } from "notistack"; -import { useAtomValue } from "jotai"; -import Typography from "@mui/material/Typography"; +import { Typography } from "@mui/material"; -import API from "apis"; import { useWeb3 } from "connectors/context"; -import { Event, NetworkIdentifier } from "types"; -import { activeDialogAtom } from "states/atoms"; -import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; function HomePage() { const { isActive, networkId } = useWeb3(); - const [data, setData] = React.useState(); - const activeDialog = useAtomValue(activeDialogAtom); - const { enqueueSnackbar } = useSnackbar(); const tooltip = React.useMemo( () => ( @@ -24,77 +14,21 @@ function HomePage() { Information Connect a wallet to continue. This will allow you to join public - events, create new events and claim NFTs from events you're attending. + events, create new events and claim NFTs of events you're attending. ), [] ); - React.useEffect(() => { - let mounted = true; - - const load = async () => { - try { - const events = await API.events.getPublic({ - networkId: networkId ?? NetworkIdentifier.UNKNOWN, - limit: 100, - }); - - if (mounted) { - setData(events); - } - } catch (err) { - console.debug(err); - if (mounted) { - setData(undefined); - } - if (axios.isAxiosError(err)) { - enqueueSnackbar(`Failed to load events data: ${err.response?.data.error}`, { - variant: "error", - }); - } else { - enqueueSnackbar("Failed to load events data", { - variant: "error", - }); - } - } - }; - - // only update data, if no dialog is open - if (!activeDialog.type) { - load(); - } - - return () => { - mounted = false; - }; - }, [activeDialog, isActive, networkId]); - - const rows = React.useMemo(() => { - if (data) { - return data.map((event) => ({ - id: event.id, - status: event.status, - title: event.title, - dateStart: new Date(event.dateStart), - dateEnd: new Date(event.dateEnd), - slotsTaken: event.attendees?.length, - slotsTotal: event.tokenCount, - })); - } else { - return []; - } - }, [data]); - return ( - + Welcome! ); } diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index e0ecc6e..aaf839f 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -34,7 +34,6 @@ function OrganizerPage() { const events = await API.events.getOwned(jwt, { networkId: networkId, limit: 100, - includeAttendees: true, }); const [usedSlots, maxSlots] = await API.user.getSlots(jwt, { diff --git a/src/types.ts b/src/types.ts index cc93338..7fe68b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,16 @@ export type Minter = { isConfigured: boolean; }; +export type Accounting = { + id: number; + depositValue: number; + depositTxHash?: string; + refundValue?: number; + refundTxHash?: string; + accumulatedTxFees: number; + eventId: Event["id"]; +}; + export type Event = { id: number; status: EventStatus; @@ -61,7 +71,9 @@ export type Event = { isManaged: boolean; ownerWalletAddress: User["walletAddress"]; owner?: User; + accounting?: Accounting; attendees?: User[]; + nfts?: NFT[]; }; export type NFT = { From 2a64ab4190f414a0560d73895f483408791869b1 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 4 Sep 2023 22:21:56 +0200 Subject: [PATCH 058/135] add admin event list --- src/pages/AdminEventsPage.tsx | 64 ++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminEventsPage.tsx b/src/pages/AdminEventsPage.tsx index cddaaaa..56db88a 100644 --- a/src/pages/AdminEventsPage.tsx +++ b/src/pages/AdminEventsPage.tsx @@ -25,13 +25,75 @@ function AdminEventsPage() { return isAuthenticated && permissions.includes("admin"); }, [isAuthenticated, permissions]); + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (networkId && jwt) { + const events = await API.events.getAll(jwt, { + networkId: networkId, + limit: 100, + }); + + if (mounted) { + setData(events); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load events data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load events data", { + variant: "error", + }); + } + } + }; + + if (isAuthorized) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [isAuthorized, isActive, networkId, jwt]); + + const rows = React.useMemo(() => { + if (data) { + return data.map((event) => ({ + id: event.id, + status: event.status, + title: event.title, + dateStart: new Date(event.dateStart), + dateEnd: new Date(event.dateEnd), + slotsTaken: event.attendees?.length, + slotsTotal: event.tokenCount, + })); + } else { + return []; + } + }, [data]); + return ( - Events + ); } From 82baea76d67f742d8e8419f25ee8657c2d819248 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 4 Sep 2023 22:45:45 +0200 Subject: [PATCH 059/135] update API --- src/apis/admin.ts | 20 ++++++++++++++++++++ src/apis/event.ts | 10 +++++----- src/apis/index.ts | 2 ++ src/apis/offers.ts | 6 +++--- src/pages/AttendeePage.tsx | 4 ++-- src/pages/ClaimPage.tsx | 4 ++-- src/types.ts | 25 ++++++++++++++++--------- 7 files changed, 50 insertions(+), 21 deletions(-) create mode 100644 src/apis/admin.ts diff --git a/src/apis/admin.ts b/src/apis/admin.ts new file mode 100644 index 0000000..afba399 --- /dev/null +++ b/src/apis/admin.ts @@ -0,0 +1,20 @@ +import axios from "axios"; +import config from "config"; + +// TODO return type +export const getStats = async ( + jwt: string, +): Promise => { + const response = await axios.get( + new URL("/admin/stats", config.apiURL).toString(), + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + } + ); + + return response.data.result as boolean; +}; diff --git a/src/apis/event.ts b/src/apis/event.ts index bd8a1cf..e3155bb 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -1,7 +1,7 @@ import axios from "axios"; import config from "config"; -import type { Event, Offer, Minter } from "types"; +import type { Event, Claim, Minter } from "types"; import { NetworkIdentifier } from "types"; export type getMinterParams = { @@ -68,7 +68,7 @@ export type joinData = { createOffer: boolean; }; -export const join = async (jwt: string, data: joinData): Promise => { +export const join = async (jwt: string, data: joinData): Promise => { const response = await axios.post( new URL("/event/join", config.apiURL).toString(), data, @@ -81,14 +81,14 @@ export const join = async (jwt: string, data: joinData): Promise => { } ); - return response.data.result as Offer; + return response.data.result as Claim; }; export type claimData = { maskedEventId: string; }; -export const claim = async (jwt: string, data: claimData): Promise => { +export const claim = async (jwt: string, data: claimData): Promise => { const response = await axios.post( new URL("/event/claim", config.apiURL).toString(), data, @@ -101,7 +101,7 @@ export const claim = async (jwt: string, data: claimData): Promise } ); - return response.data.result as Offer | null; + return response.data.result as Claim | null; }; export type inviteData = { diff --git a/src/apis/index.ts b/src/apis/index.ts index 69b8623..3dfa1bd 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,3 +1,4 @@ +import * as admin from "./admin"; import * as auth from "./auth"; import * as event from "./event"; import * as events from "./events"; @@ -6,6 +7,7 @@ import * as user from "./user"; import * as users from "./users"; export const API = { + admin, auth, event, events, diff --git a/src/apis/offers.ts b/src/apis/offers.ts index 3fe7851..d343933 100644 --- a/src/apis/offers.ts +++ b/src/apis/offers.ts @@ -1,7 +1,7 @@ import axios from "axios"; import config from "config"; -import type { Offer } from "types"; +import type { Claim } from "types"; import { NetworkIdentifier } from "types"; export type getAllParams = { @@ -12,7 +12,7 @@ export type getAllParams = { export const getAll = async ( jwt: string, params: getAllParams -): Promise => { +): Promise => { const response = await axios.get( new URL("/offers", config.apiURL).toString(), { @@ -25,5 +25,5 @@ export const getAll = async ( } ); - return response.data.result as Offer[]; + return response.data.result as Claim[]; }; diff --git a/src/pages/AttendeePage.tsx b/src/pages/AttendeePage.tsx index 51d185f..22426c9 100644 --- a/src/pages/AttendeePage.tsx +++ b/src/pages/AttendeePage.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { useSnackbar } from "notistack"; import { activeDialogAtom } from "states/atoms"; -import { Offer } from "types"; +import { Claim } from "types"; import { useAuth } from "components/AuthContext"; import { useWeb3 } from "connectors/context"; import API from "apis"; @@ -14,7 +14,7 @@ import EventTable, { type EventTableRow } from "components/EventTable"; function AttendeePage() { const { isActive, networkId } = useWeb3(); const { isAuthenticated, jwt } = useAuth(); - const [data, setData] = React.useState(); + const [data, setData] = React.useState(); const activeDialog = useAtomValue(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); diff --git a/src/pages/ClaimPage.tsx b/src/pages/ClaimPage.tsx index 7487add..bc49946 100644 --- a/src/pages/ClaimPage.tsx +++ b/src/pages/ClaimPage.tsx @@ -19,7 +19,7 @@ import { } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; -import { Offer } from "types"; +import { Claim } from "types"; import { useAuth } from "components/AuthContext"; import { useWeb3 } from "connectors/context"; import API from "apis"; @@ -77,7 +77,7 @@ function ClaimPage() { const { connector, provider, account, isActive } = useWeb3(); const { isAuthenticated, jwt, permissions, isAuto, toggleAuto } = useAuth(); const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); - const [data, setData] = React.useState(); + const [data, setData] = React.useState(); const [uuid, setUuid] = React.useState(); const [errors, setErrors] = React.useState(steps.map(() => "")); const [completed, setCompleted] = React.useState(); diff --git a/src/types.ts b/src/types.ts index 7fe68b3..9209b21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -39,12 +39,11 @@ export type User = { lastName?: string; email?: string; isOrganizer: boolean; + isAdmin: boolean; slots: number; -}; - -export type Minter = { - walletAddress: string; - isConfigured: boolean; + events?: Event[]; + attendances?: Event[]; + claims?: Claim[]; }; export type Accounting = { @@ -55,6 +54,7 @@ export type Accounting = { refundTxHash?: string; accumulatedTxFees: number; eventId: Event["id"]; + event?: Event; }; export type Event = { @@ -64,6 +64,7 @@ export type Event = { title: string; description: string; location: string; + imageUrl: string; uri: string; tokenCount: number; dateStart: string; @@ -79,21 +80,27 @@ export type Event = { export type NFT = { id: string; issuerWalletAddress: User["walletAddress"]; - eventId: Event["id"]; issuer?: User; + eventId: Event["id"]; event: Event; + claim?: Claim; }; -export type Offer = { +export type Claim = { id: number; - ownerWalletAddress: User["walletAddress"]; - tokenId: NFT["id"]; offerIndex: string | null; claimed: boolean; + ownerWalletAddress: User["walletAddress"]; owner?: User; + tokenId: NFT["id"]; token: NFT; }; +export type Minter = { + walletAddress: string; + isConfigured: boolean; +}; + export type Metadata = { title: string; description: string; From 4d92566b6a333016df65e7b37eda0f5852bc4ed4 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 4 Sep 2023 23:46:06 +0200 Subject: [PATCH 060/135] update /users API --- src/apis/users.ts | 23 +++++++++++++++++++---- src/components/AddDialog.tsx | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/apis/users.ts b/src/apis/users.ts index f39f42e..6869cb6 100644 --- a/src/apis/users.ts +++ b/src/apis/users.ts @@ -1,11 +1,11 @@ import axios from "axios"; import config from "config"; -export const getAll = async ( - jwt: string, -): Promise => { +import type { User } from "types"; + +export const getAddresses = async (jwt: string): Promise => { const response = await axios.get( - new URL("/users", config.apiURL).toString(), + new URL("/users/lookup", config.apiURL).toString(), { headers: { Authorization: `Bearer ${jwt}`, @@ -17,3 +17,18 @@ export const getAll = async ( return response.data.result as string[]; }; + +export const getOrganizers = async (jwt: string): Promise => { + const response = await axios.get( + new URL("/users/organizers", config.apiURL).toString(), + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + } + ); + + return response.data.result as User[]; +}; diff --git a/src/components/AddDialog.tsx b/src/components/AddDialog.tsx index 1b5e8f9..02d63d8 100644 --- a/src/components/AddDialog.tsx +++ b/src/components/AddDialog.tsx @@ -112,7 +112,7 @@ function AddDialog() { const load = async () => { try { if (jwt) { - const addresses = await API.users.getAll(jwt); + const addresses = await API.users.getAddresses(jwt); if (mounted) { setOptions( From 27615f606a45da7527872c7a3b7e9c9653a95420 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 4 Sep 2023 23:50:14 +0200 Subject: [PATCH 061/135] update admin users page --- src/pages/AdminUsersPage.tsx | 54 +++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/src/pages/AdminUsersPage.tsx b/src/pages/AdminUsersPage.tsx index e98762e..fa02f41 100644 --- a/src/pages/AdminUsersPage.tsx +++ b/src/pages/AdminUsersPage.tsx @@ -7,28 +7,68 @@ import { Box, Button } from "@mui/material"; import API from "apis"; import { useWeb3 } from "connectors/context"; -import { DialogIdentifier, Event } from "types"; -import { activeDialogAtom } from "states/atoms"; -import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; +import type { User } from "types"; function AdminUsersPage() { const { isActive, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); - const [data, setData] = React.useState(); - const [slots, setSlots] = React.useState<{ used: number; max: number }>(); - const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const [data, setData] = React.useState(); const { enqueueSnackbar } = useSnackbar(); const isAuthorized = React.useMemo(() => { return isAuthenticated && permissions.includes("admin"); }, [isAuthenticated, permissions]); + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (jwt) { + const users = await API.users.getOrganizers(jwt); + console.log(users); // TODO + + if (mounted) { + setData(users); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load users data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load users data", { + variant: "error", + }); + } + } + }; + + if (isAuthorized) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [isAuthorized, jwt]); + return ( Users From 05ff5e6df4bb1d86804f3e6a5896c21ee671d7ab Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 5 Sep 2023 08:01:29 +0200 Subject: [PATCH 062/135] rename mint -> create dialog --- .../{MintDialog.tsx => CreateDialog.tsx} | 24 +++++++++---------- src/layouts/MainLayout.tsx | 4 ++-- src/pages/OrganizerPage.tsx | 2 +- src/types.ts | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) rename src/components/{MintDialog.tsx => CreateDialog.tsx} (94%) diff --git a/src/components/MintDialog.tsx b/src/components/CreateDialog.tsx similarity index 94% rename from src/components/MintDialog.tsx rename to src/components/CreateDialog.tsx index 5b91431..d08d9ef 100644 --- a/src/components/MintDialog.tsx +++ b/src/components/CreateDialog.tsx @@ -101,9 +101,9 @@ const schemaDates = object({ const schema = intersection(schemaCommon, schemaDates); -type MintFormValues = TypeOf; +type CreateFormValues = TypeOf; -const defaultValues: DefaultValues = { +const defaultValues: DefaultValues = { title: "", description: "", location: "", @@ -114,11 +114,11 @@ const defaultValues: DefaultValues = { isPublic: true, }; -type MintDialogProps = { +type CreateDialogProps = { children?: ReactNode; }; -function MintDialog(props: MintDialogProps) { +function CreateDialog(props: CreateDialogProps) { const { account, networkId } = useWeb3(); const { isAuthenticated, jwt } = useAuth(); const [open, setOpen] = React.useState(false); @@ -132,14 +132,14 @@ function MintDialog(props: MintDialogProps) { formState: { errors, isValid }, reset, handleSubmit, - } = useForm({ + } = useForm({ mode: "all", defaultValues: defaultValues, resolver: zodResolver(schema), }); React.useEffect(() => { - setOpen(activeDialog.type === DialogIdentifier.DIALOG_MINT); + setOpen(activeDialog.type === DialogIdentifier.DIALOG_CREATE); }, [activeDialog]); const handleClose = (event: {}, reason?: string) => { @@ -154,7 +154,7 @@ function MintDialog(props: MintDialogProps) { setActiveDialog({}); }; - const onSubmit: SubmitHandler = async (values) => { + const onSubmit: SubmitHandler = async (values) => { setLoading(true); try { if (account && networkId && jwt) { @@ -169,8 +169,8 @@ function MintDialog(props: MintDialogProps) { dateEnd: values.dateEnd!, isManaged: !values.isPublic, }); - console.debug("MintResult", result); - enqueueSnackbar(`Mint successful: Event #${result.eventId}`, { + console.debug("CreateResult", result); + enqueueSnackbar(`Creation successful: Event #${result.eventId}`, { variant: "success", }); reset(); @@ -178,11 +178,11 @@ function MintDialog(props: MintDialogProps) { } catch (err) { console.debug(err); if (axios.isAxiosError(err)) { - enqueueSnackbar(`Mint failed: ${err.response?.data.error}`, { + enqueueSnackbar(`Creation failed: ${err.response?.data.error}`, { variant: "error", }); } else { - enqueueSnackbar(`Mint failed: ${(err as Error).message}`, { + enqueueSnackbar(`Creation failed: ${(err as Error).message}`, { variant: "error", }); } @@ -383,4 +383,4 @@ function MintDialog(props: MintDialogProps) { ); } -export default MintDialog; +export default CreateDialog; diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 35673ec..d45746f 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -11,7 +11,7 @@ import ClaimDialog from "components/ClaimDialog"; import Header from "components/Header"; import JoinDialog from "components/JoinDialog"; import LinkDialog from "components/LinkDialog" -import MintDialog from "components/MintDialog"; +import CreateDialog from "components/CreateDialog"; import ProfileDialog from "components/ProfileDialog"; function MainLayout(props: any) { @@ -33,9 +33,9 @@ function MainLayout(props: any) { + - { // mount component every time the dialog is opened to ensure // the latest values from the database are loaded diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index aaf839f..ba8d4c2 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -101,7 +101,7 @@ function OrganizerPage() { }, [data]); const handleClick = (event: React.MouseEvent) => { - setActiveDialog({ type: DialogIdentifier.DIALOG_MINT }); + setActiveDialog({ type: DialogIdentifier.DIALOG_CREATE }); }; return ( diff --git a/src/types.ts b/src/types.ts index 9209b21..2df023c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,9 +1,9 @@ export enum DialogIdentifier { DIALOG_ADD, DIALOG_CLAIM, + DIALOG_CREATE, DIALOG_JOIN, DIALOG_LINK, - DIALOG_MINT, DIALOG_PROFILE, } From 305a1025040fe45b1726479559a82cc9f4e5694f Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 5 Sep 2023 17:40:38 +0200 Subject: [PATCH 063/135] update admin pages --- src/apis/admin.ts | 13 ++++-- src/components/UserTable.tsx | 87 +++++++++++++++++++++++++++++++++++ src/pages/AdminEventsPage.tsx | 14 ++---- src/pages/AdminStatsPage.tsx | 59 +++++++++++++++++++++--- src/pages/AdminUsersPage.tsx | 31 ++++++++++--- src/types.ts | 17 +++++++ 6 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 src/components/UserTable.tsx diff --git a/src/apis/admin.ts b/src/apis/admin.ts index afba399..df244b7 100644 --- a/src/apis/admin.ts +++ b/src/apis/admin.ts @@ -1,10 +1,16 @@ import axios from "axios"; import config from "config"; -// TODO return type +import type { NetworkIdentifier, PlatformStats } from "types"; + +export type getStatsParams = { + networkId: NetworkIdentifier; +}; + export const getStats = async ( jwt: string, -): Promise => { + params: getStatsParams +): Promise => { const response = await axios.get( new URL("/admin/stats", config.apiURL).toString(), { @@ -13,8 +19,9 @@ export const getStats = async ( }, responseType: "json", timeout: config.timeout, + params: params, } ); - return response.data.result as boolean; + return response.data.result as PlatformStats; }; diff --git a/src/components/UserTable.tsx b/src/components/UserTable.tsx new file mode 100644 index 0000000..7225a13 --- /dev/null +++ b/src/components/UserTable.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import clsx from "clsx"; + +import { + GridColDef, + GridRowClassNameParams, + GridTreeNodeWithRender, + GridValueGetterParams, +} from "@mui/x-data-grid"; + +import DataTable from "components/DataTable"; + +export type UserTableRow = { + id: number; + walletAddress: string; + isOrganizer: boolean; + eventCount?: number; + totalDeposit?: string; +}; + +export type EventTableProps = { + rows: UserTableRow[]; +}; + +type GetterParamsType = GridValueGetterParams< + UserTableRow, + any, + GridTreeNodeWithRender +>; + +export function UserTable({ rows }: EventTableProps) { + const rowClassName = (params: GridRowClassNameParams) => { + return clsx("data-table", { + isOrganizer: params.row.isOrganizer, + }); + }; + + const columns = React.useMemo[]>( + () => [ + { + field: "walletAddress", + headerName: "Wallet Address", + type: "string", + width: 320, + }, + { + field: "eventCount", + headerName: "# Events", + type: "number", + width: 110, + }, + { + field: "totalDeposit", + headerName: "Total XRP Deposit", + type: "number", + width: 170, + }, + { + field: "actions", + type: "actions", + width: 45, + minWidth: 45, + getActions: (params) => { + return []; + }, + }, + ], + [] + ); + + return ( + theme.palette.grey[500], + // }, + } + } + columns={columns} + rows={rows} + rowClassName={rowClassName} + /> + ); +} + +export default UserTable; diff --git a/src/pages/AdminEventsPage.tsx b/src/pages/AdminEventsPage.tsx index 56db88a..0f5784a 100644 --- a/src/pages/AdminEventsPage.tsx +++ b/src/pages/AdminEventsPage.tsx @@ -1,24 +1,18 @@ import React from "react"; import axios from "axios"; import { useSnackbar } from "notistack"; -import { useAtom } from "jotai"; - -import { Box, Button } from "@mui/material"; import API from "apis"; import { useWeb3 } from "connectors/context"; -import { DialogIdentifier, Event } from "types"; -import { activeDialogAtom } from "states/atoms"; +import type { Event } from "types"; import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; function AdminEventsPage() { - const { isActive, networkId } = useWeb3(); + const { networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); - const [slots, setSlots] = React.useState<{ used: number; max: number }>(); - const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); const isAuthorized = React.useMemo(() => { @@ -69,7 +63,7 @@ function AdminEventsPage() { return () => { mounted = false; }; - }, [isAuthorized, isActive, networkId, jwt]); + }, [isAuthorized, networkId, jwt]); const rows = React.useMemo(() => { if (data) { @@ -89,7 +83,7 @@ function AdminEventsPage() { return ( diff --git a/src/pages/AdminStatsPage.tsx b/src/pages/AdminStatsPage.tsx index 8a27f64..22a25cd 100644 --- a/src/pages/AdminStatsPage.tsx +++ b/src/pages/AdminStatsPage.tsx @@ -1,6 +1,6 @@ import React from "react"; +import axios from "axios"; import { useSnackbar } from "notistack"; -import { useAtom } from "jotai"; import { Box, @@ -14,9 +14,7 @@ import { import API from "apis"; import { useWeb3 } from "connectors/context"; -import { DialogIdentifier, Event } from "types"; -import { activeDialogAtom } from "states/atoms"; -import EventTable, { type EventTableRow } from "components/EventTable"; +import type { PlatformStats } from "types"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; import PieChart from "components/PieChart"; @@ -24,15 +22,62 @@ import PieChart from "components/PieChart"; function AdminStatsPage() { const { isActive, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); - const [data, setData] = React.useState(); - const [slots, setSlots] = React.useState<{ used: number; max: number }>(); - const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const [data, setData] = React.useState(); const { enqueueSnackbar } = useSnackbar(); const isAuthorized = React.useMemo(() => { return isAuthenticated && permissions.includes("admin"); }, [isAuthenticated, permissions]); + // TODO add live updating on 10s timer ? + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (networkId && jwt) { + const stats = await API.admin.getStats(jwt, { + networkId: networkId, + }); + + // TODO + console.log(stats); + + if (mounted) { + setData(stats); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load stats data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load stats data", { + variant: "error", + }); + } + } + }; + + if (isAuthorized) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [isAuthorized, networkId, jwt]); + return ( diff --git a/src/pages/AdminUsersPage.tsx b/src/pages/AdminUsersPage.tsx index fa02f41..8dbb931 100644 --- a/src/pages/AdminUsersPage.tsx +++ b/src/pages/AdminUsersPage.tsx @@ -1,18 +1,15 @@ import React from "react"; import axios from "axios"; +import { dropsToXrp } from "xrpl"; import { useSnackbar } from "notistack"; -import { useAtom } from "jotai"; - -import { Box, Button } from "@mui/material"; import API from "apis"; -import { useWeb3 } from "connectors/context"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; import type { User } from "types"; +import UserTable, { UserTableRow } from "components/UserTable"; function AdminUsersPage() { - const { isActive, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); const { enqueueSnackbar } = useSnackbar(); @@ -65,13 +62,33 @@ function AdminUsersPage() { }; }, [isAuthorized, jwt]); + const rows = React.useMemo(() => { + if (data) { + return data.map((user, index) => ({ + id: index, + walletAddress: user.walletAddress, + isOrganizer: user.isOrganizer, + eventCount: user.events?.length, + totalDeposit: dropsToXrp( + user.events?.reduce( + (accumulator, event) => + accumulator + (event.accounting?.depositValue ?? 0), + 0 + ) ?? 0 + ), + })); + } else { + return []; + } + }, [data]); + return ( - Users + ); } diff --git a/src/types.ts b/src/types.ts index 2df023c..b53bb7f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -111,6 +111,23 @@ export type Metadata = { dateEnd: string; }; +export type PlatformStats = { + users: { + total: number; + organizers: number; + }; + events: { + total: number; + pending: number; + active: number; + finished: number; + }; + account: { + balance: string; + reserve: string; + }; +}; + export type JwtPayload = { exp: number; walletAddress: string; From eb3b51f580f0cf394ba001b35f0d33861e5df5be Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 7 Sep 2023 08:49:14 +0200 Subject: [PATCH 064/135] backup new create dialog --- src/components/CreateDialog.tsx | 386 ------------------ .../CreateDialog/AuthorizationStep.tsx | 170 ++++++++ src/components/CreateDialog/CreateDialog.tsx | 315 ++++++++++++++ src/components/CreateDialog/CreationStep.tsx | 339 +++++++++++++++ src/components/CreateDialog/PaymentStep.tsx | 61 +++ src/components/CreateDialog/SummaryStep.tsx | 59 +++ src/components/CreateDialog/index.ts | 1 + src/components/CreateDialog/types.ts | 12 + 8 files changed, 957 insertions(+), 386 deletions(-) delete mode 100644 src/components/CreateDialog.tsx create mode 100644 src/components/CreateDialog/AuthorizationStep.tsx create mode 100644 src/components/CreateDialog/CreateDialog.tsx create mode 100644 src/components/CreateDialog/CreationStep.tsx create mode 100644 src/components/CreateDialog/PaymentStep.tsx create mode 100644 src/components/CreateDialog/SummaryStep.tsx create mode 100644 src/components/CreateDialog/index.ts create mode 100644 src/components/CreateDialog/types.ts diff --git a/src/components/CreateDialog.tsx b/src/components/CreateDialog.tsx deleted file mode 100644 index d08d9ef..0000000 --- a/src/components/CreateDialog.tsx +++ /dev/null @@ -1,386 +0,0 @@ -import React from "react"; -import axios from "axios"; -import { useAtom } from "jotai"; -import type { ReactNode } from "react"; -import { useSnackbar } from "notistack"; -import { - useForm, - SubmitHandler, - Controller, - DefaultValues, -} from "react-hook-form"; -import { - object, - boolean, - string, - number, - date, - intersection, - TypeOf, -} from "zod"; -import { zodResolver } from "@hookform/resolvers/zod"; - -import Button from "@mui/material/Button"; -import Checkbox from "@mui/material/Checkbox"; -import CloseIcon from "@mui/icons-material/Close"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import FormGroup from "@mui/material/FormGroup"; -import IconButton from "@mui/material/IconButton"; -import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import Typography from "@mui/material/Typography"; -import CircularProgress from "@mui/material/CircularProgress"; -import { DatePicker } from "@mui/x-date-pickers/DatePicker"; - -import API from "apis"; -import { useWeb3 } from "connectors/context"; -import { activeDialogAtom } from "states/atoms"; -import { DialogIdentifier } from "types"; -import { useAuth } from "components/AuthContext"; - -const schemaCommon = object({ - title: string() - .nonempty("Title is required") - .max(256, "Title must be less than 256 characters") - .trim(), - description: string() - .nonempty("Description is required") - .max(10000, "Description must be less than 10000 characters"), - location: string() - .nonempty("Location is required") - .max(256, "Location must be less than 256 characters"), - url: string().nonempty("URL is required").url("URL is invalid").trim(), - tokenCount: number() - .int() - .positive() - .max(200, "Token count must be less than or equal to 200"), - isPublic: boolean(), -}); - -// Note: We allow nullable for the DatePicker component to work. -// The final value will always be a valid Date. -const schemaDates = object({ - dateStart: date() - .min(new Date("1900-01-01"), "Date is too far back") - .nullable() - .transform((value, ctx) => { - if (value == null) - ctx.addIssue({ - code: "custom", - message: "Start Date is required", - }); - return value; - }), - dateEnd: date() - .min(new Date("1900-01-01"), "Date is too far back") - .nullable() - .transform((value, ctx) => { - if (value == null) - ctx.addIssue({ - code: "custom", - message: "End Date is required", - }); - return value; - }), -}).refine( - (data) => { - if (data.dateEnd && data.dateStart) { - return data.dateEnd >= data.dateStart; - } - return false; - }, - { - path: ["dateEnd"], - message: "End Date must be later than Start Date", - } -); - -const schema = intersection(schemaCommon, schemaDates); - -type CreateFormValues = TypeOf; - -const defaultValues: DefaultValues = { - title: "", - description: "", - location: "", - url: "", - dateStart: null, - dateEnd: null, - tokenCount: undefined, - isPublic: true, -}; - -type CreateDialogProps = { - children?: ReactNode; -}; - -function CreateDialog(props: CreateDialogProps) { - const { account, networkId } = useWeb3(); - const { isAuthenticated, jwt } = useAuth(); - const [open, setOpen] = React.useState(false); - const [loading, setLoading] = React.useState(false); - const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); - const { enqueueSnackbar } = useSnackbar(); - - const { - control, - register, - formState: { errors, isValid }, - reset, - handleSubmit, - } = useForm({ - mode: "all", - defaultValues: defaultValues, - resolver: zodResolver(schema), - }); - - React.useEffect(() => { - setOpen(activeDialog.type === DialogIdentifier.DIALOG_CREATE); - }, [activeDialog]); - - const handleClose = (event: {}, reason?: string) => { - if (reason === "backdropClick") { - return; - } - setActiveDialog({}); - }; - - const handleCancel = (event: React.MouseEvent) => { - reset(); - setActiveDialog({}); - }; - - const onSubmit: SubmitHandler = async (values) => { - setLoading(true); - try { - if (account && networkId && jwt) { - const result = await API.event.create(jwt, { - networkId: networkId, - tokenCount: values.tokenCount, - title: values.title, - description: values.description, - location: values.location, - imageUrl: values.url, - dateStart: values.dateStart!, - dateEnd: values.dateEnd!, - isManaged: !values.isPublic, - }); - console.debug("CreateResult", result); - enqueueSnackbar(`Creation successful: Event #${result.eventId}`, { - variant: "success", - }); - reset(); - } - } catch (err) { - console.debug(err); - if (axios.isAxiosError(err)) { - enqueueSnackbar(`Creation failed: ${err.response?.data.error}`, { - variant: "error", - }); - } else { - enqueueSnackbar(`Creation failed: ${(err as Error).message}`, { - variant: "error", - }); - } - } finally { - setLoading(false); - setActiveDialog({}); - } - }; - - return ( - - - Create new Event - - theme.palette.grey[500], - }} - size="small" - onClick={handleClose} - disabled={loading} - > - - - - - - - - - - ( - - )} - /> - ( - - )} - /> - - - {/* ( - - - } - label={ - - Allow any platform user to join the event (public) - - } - disabled={loading} - /> - - )} - /> */} - - - - - - - - ); -} - -export default CreateDialog; diff --git a/src/components/CreateDialog/AuthorizationStep.tsx b/src/components/CreateDialog/AuthorizationStep.tsx new file mode 100644 index 0000000..fa5a569 --- /dev/null +++ b/src/components/CreateDialog/AuthorizationStep.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import axios from "axios"; + +import { useSnackbar } from "notistack"; + +import { Box, Button } from "@mui/material"; + +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { useAuth } from "components/AuthContext"; +import Loader from "components/Loader"; +import { StepProps } from "./types"; +import { Minter } from "types"; + +function AuthorizationStep({ + active, + loading, + setLoading, + setError, + setComplete, + setActions, + close, +}: StepProps) { + const { provider, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + // const [loading, setLoading] = React.useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); + + // TODO set errors + + // update parent state + React.useEffect(() => { + setComplete(Boolean(data?.isConfigured)); + }, [data]); + + // fetch authorized minter info + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (networkId && jwt) { + const minter = await API.event.getMinter(jwt, { + networkId: networkId, + }); + + // TODO + console.log(minter); + + if (mounted) { + setData(minter); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load stats data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load stats data", { + variant: "error", + }); + } + } + }; + + if (isAuthorized) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [isAuthorized, networkId, jwt]); + + // TODO + const handleConfirm = async (event: React.MouseEvent) => { + setLoading(true); + try { + if (provider && data?.walletAddress) { + const result = await provider.setAccount(data.walletAddress); + const success = await result.resolved; + + console.log(success); + // TODO force redownload minter + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + } + }; + + // // set actions + // React.useEffect(() => { + // console.log("setting actions", active); + // if (active) { + // setActions([ + // , + // ]); + // } else { + // setActions([]); + // } + // }, [active, loading, isAuthorized, data, handleConfirm]); + + // TODO display "checking minter status" while we load the status with spinner + // TODO + return active ? ( + +

    isAuthorized: {isAuthorized ? "true" : "false"}

    + + {data ? ( +
    +

    isConfigured: {data.isConfigured ? "true" : "false"}

    +

    minter address: {data.walletAddress}

    +
    + ) : ( +
    + +
    + )} + + + +
    + ) : null; +} + +export default AuthorizationStep; diff --git a/src/components/CreateDialog/CreateDialog.tsx b/src/components/CreateDialog/CreateDialog.tsx new file mode 100644 index 0000000..28c5770 --- /dev/null +++ b/src/components/CreateDialog/CreateDialog.tsx @@ -0,0 +1,315 @@ +import React from "react"; +import axios from "axios"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +import { + Box, + Button, + Stepper, + Step, + StepLabel, + StepContent, + Paper, + Typography, +} from "@mui/material"; + +import Checkbox from "@mui/material/Checkbox"; +import CloseIcon from "@mui/icons-material/Close"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import FormControlLabel from "@mui/material/FormControlLabel"; +import FormGroup from "@mui/material/FormGroup"; +import IconButton from "@mui/material/IconButton"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import CircularProgress from "@mui/material/CircularProgress"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; +import { useAuth } from "components/AuthContext"; +import AuthorizationStep from "./AuthorizationStep"; +import CreationStep from "./CreationStep"; +import SummaryStep from "./SummaryStep"; +import PaymentStep from "./PaymentStep"; + +enum Steps { + AUTHORIZATION, + CREATION, + PAYMENT, + SUMMARY, +} + +type StepInfo = { + id: Steps; + label: string; + description: string; +}; + +const stepInfo: StepInfo[] = [ + { + id: Steps.AUTHORIZATION, + label: "Authorize Minter", + description: `For each ad campaign that you create, you can control how much + you're willing to spend on clicks and conversions, which networks + and geographical locations you want your ads to show on, and more.`, + }, + { + id: Steps.CREATION, + label: "Create Event", + description: + "An ad group contains one or more ads which target a shared set of keywords.", + }, + { + id: Steps.PAYMENT, + label: "Submit Payment", + description: `Try out different ad text to see what brings in the most customers, + and learn how to enhance your ads using features like ad extensions. + If you run into any problems with your ads, find out how to tell if + they're running and how to resolve approval issues.`, + }, + { + id: Steps.SUMMARY, + label: "Summary", + description: `TODO SHOW QR code and link: + Try out different ad text to see what brings in the most customers, + and learn how to enhance your ads using features like ad extensions. + If you run into any problems with your ads, find out how to tell if + they're running and how to resolve approval issues.`, + }, +]; + +// TODO how to solve loading icon ? +// just create react node, easiest ? +// type DialogAction = { label: string; callback: () => void; disabled?: boolean }; + +type State = { + loading: boolean; + activeStep: Steps; + dialogActions: { [key in Steps]: React.ReactNode[] }; + error: { [key in Steps]: string | null }; + complete: { [key in Steps]: boolean }; +}; + +type Actions = { + setLoading: (value: boolean) => void; + setActiveStep: (step: Steps) => void; + setError: (step: Steps, text: string | null) => void; + setComplete: (step: Steps, value: boolean) => void; + setDialogActions: (step: Steps, actions: React.ReactNode[]) => void; + reset: () => void; +}; + +const initialState: State = { + loading: false, + activeStep: Steps.AUTHORIZATION, + dialogActions: { + [Steps.AUTHORIZATION]: [], + [Steps.CREATION]: [], + [Steps.PAYMENT]: [], + [Steps.SUMMARY]: [], + }, + error: { + [Steps.AUTHORIZATION]: null, + [Steps.CREATION]: null, + [Steps.PAYMENT]: null, + [Steps.SUMMARY]: null, + }, + complete: { + [Steps.AUTHORIZATION]: false, + [Steps.CREATION]: false, + [Steps.PAYMENT]: false, + [Steps.SUMMARY]: false, + }, +}; + +const useStore = create()((set, get) => ({ + ...initialState, + setComplete: (step: Steps, value: boolean) => { + set({ complete: { ...get().complete, [step]: value } }); + }, + setError: (step: Steps, text: string | null) => { + set({ error: { ...get().error, [step]: text } }); + }, + setLoading: (value: boolean) => { + set({ loading: value }); + }, + setActiveStep: (step: Steps) => { + set({ activeStep: step }); + }, + setDialogActions: (step: Steps, actions: React.ReactNode[]) => { + set({ dialogActions: { ...get().dialogActions, [step]: actions } }); + }, + reset: () => { + set(initialState); + }, +})); + +function CreateDialog() { + const { account, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + // const [state, dispatch] = React.useReducer(); + const state = useStore(); + const [open, setOpen] = React.useState(false); + // const [loading, setLoading] = React.useState(false); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + // const [activeStep, setActiveStep] = React.useState(0); + const { enqueueSnackbar } = useSnackbar(); + + React.useEffect(() => { + setOpen(activeDialog.type === DialogIdentifier.DIALOG_CREATE); + }, [activeDialog]); + + // TODO where and how do we reset the state (including step components) + // might be best, if each component does it themselves + + // update active step + React.useEffect(() => { + for (const step of stepInfo) { + if (!state.complete[step.id]) { + state.setActiveStep(step.id); + break; + } + } + + // if (!state.complete[Steps.AUTHORIZATION]) { + // state.setActiveStep(Steps.AUTHORIZATION); + // return; + // } else if (!state.complete[Steps.CREATION]) { + // state.setActiveStep(Steps.CREATION); + // return; + // } + // TODO reset + // TODO does this even work (as expected)? + }, [state.complete]); + + const handleClose = (event?: {}, reason?: string) => { + if (reason === "backdropClick") { + return; + } + setActiveDialog({}); + }; + + const activeStepIndex = React.useMemo(() => { + return stepInfo.findIndex((x) => x.id === state.activeStep); + }, [state.activeStep]); + + return ( + + + Event Creation Setup + + theme.palette.grey[500], + }} + size="small" + onClick={handleClose} + disabled={state.loading} + > + + + + + + {stepInfo.map((step, index) => ( + + + {step.label} + + + {step.description} + + + ))} + + + {/* TODO loop this ? */} + state.setError(Steps.AUTHORIZATION, text)} + setComplete={(value) => state.setComplete(Steps.AUTHORIZATION, value)} + setActions={(actions) => + state.setDialogActions(Steps.AUTHORIZATION, actions) + } + close={handleClose} + /> + state.setError(Steps.CREATION, text)} + setComplete={(value) => state.setComplete(Steps.CREATION, value)} + setActions={(actions) => + state.setDialogActions(Steps.CREATION, actions) + } + close={handleClose} + /> + state.setError(Steps.PAYMENT, text)} + setComplete={(value) => state.setComplete(Steps.PAYMENT, value)} + setActions={(actions) => + state.setDialogActions(Steps.PAYMENT, actions) + } + close={handleClose} + /> + state.setError(Steps.SUMMARY, text)} + setComplete={(value) => state.setComplete(Steps.SUMMARY, value)} + setActions={(actions) => + state.setDialogActions(Steps.SUMMARY, actions) + } + close={handleClose} + /> + + + + {state.dialogActions[state.activeStep].map((x) => x)} + + + ); +} + +export default CreateDialog; diff --git a/src/components/CreateDialog/CreationStep.tsx b/src/components/CreateDialog/CreationStep.tsx new file mode 100644 index 0000000..596e4ea --- /dev/null +++ b/src/components/CreateDialog/CreationStep.tsx @@ -0,0 +1,339 @@ +import React from "react"; +import axios from "axios"; +import { useAtom } from "jotai"; +import type { ReactNode } from "react"; +import { useSnackbar } from "notistack"; +import { + useForm, + SubmitHandler, + Controller, + DefaultValues, +} from "react-hook-form"; +import { + object, + boolean, + string, + number, + date, + intersection, + TypeOf, +} from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { Box } from "@mui/material"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import CircularProgress from "@mui/material/CircularProgress"; +import { DatePicker } from "@mui/x-date-pickers/DatePicker"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { activeDialogAtom } from "states/atoms"; +import { useAuth } from "components/AuthContext"; +import { StepProps } from "./types"; + +const schemaCommon = object({ + title: string() + .nonempty("Title is required") + .max(256, "Title must be less than 256 characters") + .trim(), + description: string() + .nonempty("Description is required") + .max(10000, "Description must be less than 10000 characters"), + location: string() + .nonempty("Location is required") + .max(256, "Location must be less than 256 characters"), + url: string().nonempty("URL is required").url("URL is invalid").trim(), + tokenCount: number() + .int() + .positive() + .max(200, "Token count must be less than or equal to 200"), + isPublic: boolean(), +}); + +// Note: We allow nullable for the DatePicker component to work. +// The final value will always be a valid Date. +const schemaDates = object({ + dateStart: date() + .min(new Date("1900-01-01"), "Date is too far back") + .nullable() + .transform((value, ctx) => { + if (value == null) + ctx.addIssue({ + code: "custom", + message: "Start Date is required", + }); + return value; + }), + dateEnd: date() + .min(new Date("1900-01-01"), "Date is too far back") + .nullable() + .transform((value, ctx) => { + if (value == null) + ctx.addIssue({ + code: "custom", + message: "End Date is required", + }); + return value; + }), +}).refine( + (data) => { + if (data.dateEnd && data.dateStart) { + return data.dateEnd >= data.dateStart; + } + return false; + }, + { + path: ["dateEnd"], + message: "End Date must be later than Start Date", + } +); + +const schema = intersection(schemaCommon, schemaDates); + +type CreateFormValues = TypeOf; + +const defaultValues: DefaultValues = { + title: "", + description: "", + location: "", + url: "", + dateStart: null, + dateEnd: null, + tokenCount: undefined, + isPublic: true, +}; + +function CreationStep({ + active, + loading, + setLoading, + setError, + setComplete, + close, +}: StepProps) { + const { networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + // const [loading, setLoading] = React.useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const { + control, + register, + formState: { errors, isValid }, + reset, + handleSubmit, + } = useForm({ + mode: "all", + defaultValues: defaultValues, + resolver: zodResolver(schema), + }); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); + + // // TODO update parent state + // React.useEffect(() => { + // setComplete(Boolean(data?.isConfigured)); + // }, [data, setComplete]); + + // const handleClose = (event: {}, reason?: string) => { + // if (reason === "backdropClick") { + // return; + // } + // setActiveDialog({}); + // }; + + // const handleCancel = (event: React.MouseEvent) => { + // reset(); + // setActiveDialog({}); + // }; + + const onSubmit: SubmitHandler = async (values) => { + setLoading(true); + try { + if (networkId && isAuthorized && jwt) { + const result = await API.event.create(jwt, { + networkId: networkId, + tokenCount: values.tokenCount, + title: values.title, + description: values.description, + location: values.location, + imageUrl: values.url, + dateStart: values.dateStart!, + dateEnd: values.dateEnd!, + isManaged: !values.isPublic, + }); + console.debug("CreateResult", result); + enqueueSnackbar(`Creation successful: Event #${result.eventId}`, { + variant: "success", + }); + reset(); + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Creation failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Creation failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + // setActiveDialog({}); + } + }; + + return active ? ( + + + + + + + + ( + + )} + /> + ( + + )} + /> + + + {/* ( + + + } + label={ + + Allow any platform user to join the event (public) + + } + disabled={loading} + /> + + )} + /> */} + + + ) : null; +} + +export default CreationStep; diff --git a/src/components/CreateDialog/PaymentStep.tsx b/src/components/CreateDialog/PaymentStep.tsx new file mode 100644 index 0000000..a02227a --- /dev/null +++ b/src/components/CreateDialog/PaymentStep.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import axios from "axios"; + +import { useSnackbar } from "notistack"; + +import { Box, Button } from "@mui/material"; + +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { useAuth } from "components/AuthContext"; +import Loader from "components/Loader"; +import { StepProps } from "./types"; +import { Minter } from "types"; + +function PaymentStep({ active, setError, setComplete, close }: StepProps) { + const { provider, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + const [loading, setLoading] = React.useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); + + // TODO only load event info, if active, if not active reset + + // TODO display "checking minter status" while we load the status with spinner + return active ? ( + +

    isAuthorized: {isAuthorized ? "true" : "false"}

    + + {data ? ( +
    +

    isConfigured: {data.isConfigured ? "true" : "false"}

    +

    minter address: {data.walletAddress}

    +
    + ) : ( +
    + +
    + )} + + + +
    + ) : null; +} + +export default PaymentStep; diff --git a/src/components/CreateDialog/SummaryStep.tsx b/src/components/CreateDialog/SummaryStep.tsx new file mode 100644 index 0000000..de0864f --- /dev/null +++ b/src/components/CreateDialog/SummaryStep.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import axios from "axios"; + +import { useSnackbar } from "notistack"; + +import { Box, Button } from "@mui/material"; + +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { useAuth } from "components/AuthContext"; +import Loader from "components/Loader"; +import { StepProps } from "./types"; +import { Minter } from "types"; + +function SummaryStep({ active, setError, setComplete, close }: StepProps) { + const { provider, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + const [loading, setLoading] = React.useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); + + // TODO display "checking minter status" while we load the status with spinner + return active ? ( + +

    isAuthorized: {isAuthorized ? "true" : "false"}

    + + {data ? ( +
    +

    isConfigured: {data.isConfigured ? "true" : "false"}

    +

    minter address: {data.walletAddress}

    +
    + ) : ( +
    + +
    + )} + + + +
    + ) : null; +} + +export default SummaryStep; diff --git a/src/components/CreateDialog/index.ts b/src/components/CreateDialog/index.ts new file mode 100644 index 0000000..928de39 --- /dev/null +++ b/src/components/CreateDialog/index.ts @@ -0,0 +1 @@ +export { default } from "./CreateDialog"; diff --git a/src/components/CreateDialog/types.ts b/src/components/CreateDialog/types.ts new file mode 100644 index 0000000..c13d49e --- /dev/null +++ b/src/components/CreateDialog/types.ts @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +// Note: only use loading to enable/disable buttons +export type StepProps = { + active: boolean; + loading: boolean; + setLoading: (value: boolean) => void; + setError: (text: string | null) => void; + setComplete: (value: boolean) => void; + setActions: (actions: ReactNode[]) => void; + close: (event?: {}, reason?: string) => void; +}; From f573c915a3cf7f4814016b81aa783cf502f6fe3c Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 7 Sep 2023 08:52:49 +0200 Subject: [PATCH 065/135] fix provider --- src/components/ClaimDialog.tsx | 3 ++- src/connectors/gem.ts | 12 ++++++++++-- src/connectors/xumm.ts | 2 ++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/ClaimDialog.tsx b/src/components/ClaimDialog.tsx index f826902..3a03ff2 100644 --- a/src/components/ClaimDialog.tsx +++ b/src/components/ClaimDialog.tsx @@ -72,7 +72,8 @@ function ClaimDialog() { autoHideDuration: 30000, } ); - const success = await provider.acceptOffer(offer.offerIndex); + const result = await provider.acceptOffer(offer.offerIndex); + const success = await result.resolved; if (success) { enqueueSnackbar("Claim successful", { diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index f1e6513..f2af7e2 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -1,8 +1,13 @@ +import { AccountSetAsfFlags } from "xrpl"; import * as Gem from "@gemwallet/api"; import API from "apis"; import { Connector } from "connectors/connector"; -import { AuthData, Provider, type ProviderRequestResult } from "connectors/provider"; +import { + AuthData, + Provider, + type ProviderRequestResult, +} from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; export class NoGemWalletError extends Error { @@ -38,9 +43,12 @@ export class GemWalletProvider extends Provider { }; } - public async setAccount(minterAddress: string): Promise { + public async setAccount( + minterAddress: string + ): Promise { const response = await Gem.setAccount({ NFTokenMinter: minterAddress, + setFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, }); if (response.type === "reject") { throw Error("User refused to sign AccountSet transaction"); diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 03e055e..58c669d 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -1,3 +1,4 @@ +import { AccountSetAsfFlags } from "xrpl"; import { XummSdkJwt, SdkTypes } from "xumm-sdk"; import { XummPkce } from "xumm-oauth2-pkce"; @@ -74,6 +75,7 @@ export class XummWalletProvider extends Provider { const subscription = await this.submitPayload({ TransactionType: "AccountSet", NFTokenMinter: minterAddress, + SetFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, }); return { From f3e1ad703f95f54711608e5dade5068cc8a3437a Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 7 Sep 2023 08:53:14 +0200 Subject: [PATCH 066/135] update types --- src/apis/event.ts | 1 - src/types.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apis/event.ts b/src/apis/event.ts index e3155bb..43100fa 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -41,7 +41,6 @@ export type createData = { export type createResult = { eventId: number; - metadataUri: string; }; export const create = async ( diff --git a/src/types.ts b/src/types.ts index b53bb7f..9119dc0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,7 @@ export enum ConnectorType { export enum EventStatus { PENDING, + PAID, ACTIVE, CLOSED, CANCELED, @@ -48,7 +49,8 @@ export type User = { export type Accounting = { id: number; - depositValue: number; + depositReserveValue: number; + depositFeeValue: number; depositTxHash?: string; refundValue?: number; refundTxHash?: string; From 8d04ce20127fa75ca60efbe6bebdc066e1b716c9 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 7 Sep 2023 09:28:45 +0200 Subject: [PATCH 067/135] add login flow switch --- src/apis/auth.ts | 1 + src/components/AuthContext.tsx | 17 ++++++++++++----- src/components/Web3Status.tsx | 8 ++++++-- src/pages/ClaimPage.tsx | 7 +++++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/apis/auth.ts b/src/apis/auth.ts index ac605b6..7c4315b 100644 --- a/src/apis/auth.ts +++ b/src/apis/auth.ts @@ -37,6 +37,7 @@ export type loginData = { walletType: WalletType; data: string; signature?: string; + claimFlow: boolean; }; export const login = async (data: loginData): Promise => { diff --git a/src/components/AuthContext.tsx b/src/components/AuthContext.tsx index 55a62b1..651aa14 100644 --- a/src/components/AuthContext.tsx +++ b/src/components/AuthContext.tsx @@ -57,11 +57,13 @@ export type AuthContextType = { isAvailable: boolean; isAuthenticated: boolean; isAuto: boolean; + isClaimFlow: boolean; jwt?: string; permissions: string[]; login: () => Promise; logout: () => void; - toggleAuto: () => void; + toggleAuto: Actions["toggleAuto"]; + setClaimFlow: (value: boolean) => void; }; export const AuthContext = React.createContext( @@ -75,6 +77,7 @@ export type AuthProviderProps = { export function AuthProvider({ children }: AuthProviderProps) { const { connector, provider, account, networkId } = useWeb3(); const { isAuto, tokens, addToken, removeToken, toggleAuto } = useAuthStore(); + const [isClaimFlow, setIsClaimFlow] = React.useState(false); const [isAvailable, setIsAvailable] = React.useState(true); const [isAuthenticated, setIsAuthenticated] = React.useState(false); const [jwt, setJwt] = React.useState(); @@ -98,6 +101,7 @@ export function AuthProvider({ children }: AuthProviderProps) { walletAddress: account, walletType: WalletType.XUMM_WALLET, data: data.tempJwt, + claimFlow: isClaimFlow, }); return token; } @@ -108,12 +112,13 @@ export function AuthProvider({ children }: AuthProviderProps) { walletType: WalletType.GEM_WALLET, data: data.tempJwt, signature: data.signature, + claimFlow: isClaimFlow, }); return token; } } } - }, [account, connector, provider]); + }, [account, connector, provider, isClaimFlow]); const reset = React.useCallback(() => { setIsAuthenticated(false); @@ -129,7 +134,7 @@ export function AuthProvider({ children }: AuthProviderProps) { console.debug("Using cached jwt"); const payload = decodeToken(token); setJwt(token); - setPermissions((payload as JwtPayload)?.permissions ?? []) + setPermissions((payload as JwtPayload)?.permissions ?? []); setIsAuthenticated(true); return; } @@ -139,7 +144,7 @@ export function AuthProvider({ children }: AuthProviderProps) { if (token) { const payload = decodeToken(token); setJwt(token); - setPermissions((payload as JwtPayload)?.permissions ?? []) + setPermissions((payload as JwtPayload)?.permissions ?? []); addToken(account, token); setIsAuthenticated(true); return; @@ -202,7 +207,7 @@ export function AuthProvider({ children }: AuthProviderProps) { if (account) { load(); } else { - reset() + reset(); } } @@ -217,11 +222,13 @@ export function AuthProvider({ children }: AuthProviderProps) { isAvailable, isAuthenticated, isAuto, + isClaimFlow, jwt, permissions, login, logout, toggleAuto, + setClaimFlow: setIsClaimFlow, }} > {children} diff --git a/src/components/Web3Status.tsx b/src/components/Web3Status.tsx index 8e52af6..f5d46d2 100644 --- a/src/components/Web3Status.tsx +++ b/src/components/Web3Status.tsx @@ -15,7 +15,8 @@ import { useWeb3 } from "connectors/context"; import { xumm } from "connectors/xumm"; import { getConnector } from "connectors"; import type { Connector } from "connectors/connector"; -import { ConnectorType, DialogIdentifier } from "types"; +import { ConnectorType } from "types"; +import { useAuth } from "components/AuthContext"; const StyledMenu = styled((props: MenuProps) => ( (DEFAULT_STATUS); const [menuAnchor, setMenuAnchor] = React.useState(null); @@ -107,6 +109,7 @@ function Web3Status() { ); } } else if (action === "xumm") { + setClaimFlow(false); try { await xumm.activate(); setSelectedWallet(ConnectorType.XUMM); @@ -117,6 +120,7 @@ function Web3Status() { ); } } else if (action === "gem") { + setClaimFlow(false); try { await gem.activate(); setSelectedWallet(ConnectorType.GEM); @@ -128,7 +132,7 @@ function Web3Status() { } } }, - [connector, enqueueSnackbar, setSelectedWallet] + [connector, enqueueSnackbar, setSelectedWallet, setClaimFlow] ); return ( diff --git a/src/pages/ClaimPage.tsx b/src/pages/ClaimPage.tsx index bc49946..1c87613 100644 --- a/src/pages/ClaimPage.tsx +++ b/src/pages/ClaimPage.tsx @@ -75,7 +75,7 @@ type IView = {}; function ClaimPage() { const { connector, provider, account, isActive } = useWeb3(); - const { isAuthenticated, jwt, permissions, isAuto, toggleAuto } = useAuth(); + const { isAuthenticated, jwt, permissions, isAuto, toggleAuto, setClaimFlow } = useAuth(); const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); const [data, setData] = React.useState(); const [uuid, setUuid] = React.useState(); @@ -245,6 +245,9 @@ function ClaimPage() { } } + // set login flow + setClaimFlow(true); + setLoading(true); try { await xumm.activate(); @@ -256,7 +259,7 @@ function ClaimPage() { } finally { setLoading(false); } - }, [connector, isActive, setSelectedWallet]); + }, [connector, isActive, setSelectedWallet, setClaimFlow, enqueueSnackbar]); // TODO make useCallback const handleClaim = async (event: React.MouseEvent) => { From 64e01ddc80e08b04120c7fa9d3d2a27ad7fb76b2 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 7 Sep 2023 09:40:26 +0200 Subject: [PATCH 068/135] remove user based event slots --- src/apis/user.ts | 27 +-------------------- src/pages/OrganizerPage.tsx | 47 ++++++++----------------------------- 2 files changed, 11 insertions(+), 63 deletions(-) diff --git a/src/apis/user.ts b/src/apis/user.ts index 87c0dee..7c3ebaa 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -1,7 +1,7 @@ import axios from "axios"; import config from "config"; -import type { NetworkIdentifier, User } from "types"; +import type { User } from "types"; export type getInfoParams = { includeEvents?: boolean; @@ -50,28 +50,3 @@ export const update = async ( return response.data.result as boolean; }; - -export type getSlotsParams = { - networkId: NetworkIdentifier; -}; - -export type getSlotsResult = [number, number]; - -export const getSlots = async ( - jwt: string, - params: getSlotsParams -): Promise => { - const response = await axios.get( - new URL("/user/slots", config.apiURL).toString(), - { - headers: { - Authorization: `Bearer ${jwt}`, - }, - responseType: "json", - timeout: config.timeout, - params: params, - } - ); - - return response.data.result as getSlotsResult; -}; diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index ba8d4c2..9606fc3 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -3,7 +3,7 @@ import axios from "axios"; import { useSnackbar } from "notistack"; import { useAtom } from "jotai"; -import { Box, Button } from "@mui/material"; +import { Button } from "@mui/material"; import API from "apis"; import { useWeb3 } from "connectors/context"; @@ -17,7 +17,6 @@ function OrganizerPage() { const { isActive, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); - const [slots, setSlots] = React.useState<{ used: number; max: number }>(); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); @@ -36,23 +35,14 @@ function OrganizerPage() { limit: 100, }); - const [usedSlots, maxSlots] = await API.user.getSlots(jwt, { - networkId: networkId, - }); - if (mounted) { setData(events); - setSlots({ - used: usedSlots, - max: maxSlots, - }); } } } catch (err) { console.debug(err); if (mounted) { setData(undefined); - setSlots(undefined); } if (axios.isAxiosError(err)) { enqueueSnackbar( @@ -75,7 +65,6 @@ function OrganizerPage() { load(); } else { setData(undefined); - setSlots(undefined); } } @@ -110,31 +99,15 @@ function OrganizerPage() { isLoading={!Boolean(data)} isAuthorized={isAuthorized} secondary={ - - {slots && ( - - - Slots: {slots.used}/{slots.max} - - - )} - - + } > From 65359291a6f952de44f97783a762e674676d5335 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 7 Sep 2023 12:14:05 +0200 Subject: [PATCH 069/135] add eventId to create dialog state --- src/components/CreateDialog/CreateDialog.tsx | 15 +++++++++++++++ src/components/CreateDialog/CreationStep.tsx | 10 ++++++---- src/components/CreateDialog/types.ts | 2 ++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/components/CreateDialog/CreateDialog.tsx b/src/components/CreateDialog/CreateDialog.tsx index 28c5770..3915bf0 100644 --- a/src/components/CreateDialog/CreateDialog.tsx +++ b/src/components/CreateDialog/CreateDialog.tsx @@ -93,6 +93,7 @@ const stepInfo: StepInfo[] = [ type State = { loading: boolean; activeStep: Steps; + eventId?: number; dialogActions: { [key in Steps]: React.ReactNode[] }; error: { [key in Steps]: string | null }; complete: { [key in Steps]: boolean }; @@ -101,6 +102,7 @@ type State = { type Actions = { setLoading: (value: boolean) => void; setActiveStep: (step: Steps) => void; + setEventId: (value?: number) => void; setError: (step: Steps, text: string | null) => void; setComplete: (step: Steps, value: boolean) => void; setDialogActions: (step: Steps, actions: React.ReactNode[]) => void; @@ -110,6 +112,7 @@ type Actions = { const initialState: State = { loading: false, activeStep: Steps.AUTHORIZATION, + eventId: undefined, dialogActions: { [Steps.AUTHORIZATION]: [], [Steps.CREATION]: [], @@ -141,6 +144,9 @@ const useStore = create()((set, get) => ({ setLoading: (value: boolean) => { set({ loading: value }); }, + setEventId: (value?: number) => { + set({ eventId: value }); + }, setActiveStep: (step: Steps) => { set({ activeStep: step }); }, @@ -165,6 +171,7 @@ function CreateDialog() { React.useEffect(() => { setOpen(activeDialog.type === DialogIdentifier.DIALOG_CREATE); + state.setEventId(activeDialog.data?.eventId); }, [activeDialog]); // TODO where and how do we reset the state (including step components) @@ -260,7 +267,9 @@ function CreateDialog() { state.setError(Steps.AUTHORIZATION, text)} setComplete={(value) => state.setComplete(Steps.AUTHORIZATION, value)} setActions={(actions) => @@ -271,7 +280,9 @@ function CreateDialog() { state.setError(Steps.CREATION, text)} setComplete={(value) => state.setComplete(Steps.CREATION, value)} setActions={(actions) => @@ -282,7 +293,9 @@ function CreateDialog() { state.setError(Steps.PAYMENT, text)} setComplete={(value) => state.setComplete(Steps.PAYMENT, value)} setActions={(actions) => @@ -293,7 +306,9 @@ function CreateDialog() { state.setError(Steps.SUMMARY, text)} setComplete={(value) => state.setComplete(Steps.SUMMARY, value)} setActions={(actions) => diff --git a/src/components/CreateDialog/CreationStep.tsx b/src/components/CreateDialog/CreationStep.tsx index 596e4ea..bd73038 100644 --- a/src/components/CreateDialog/CreationStep.tsx +++ b/src/components/CreateDialog/CreationStep.tsx @@ -108,7 +108,9 @@ const defaultValues: DefaultValues = { function CreationStep({ active, loading, + eventId, setLoading, + setEventId, setError, setComplete, close, @@ -134,10 +136,10 @@ function CreationStep({ return isAuthenticated && permissions.includes("organizer"); }, [isAuthenticated, permissions]); - // // TODO update parent state - // React.useEffect(() => { - // setComplete(Boolean(data?.isConfigured)); - // }, [data, setComplete]); + // update parent state + React.useEffect(() => { + setComplete(Boolean(eventId)); + }, [eventId]); // const handleClose = (event: {}, reason?: string) => { // if (reason === "backdropClick") { diff --git a/src/components/CreateDialog/types.ts b/src/components/CreateDialog/types.ts index c13d49e..5956d71 100644 --- a/src/components/CreateDialog/types.ts +++ b/src/components/CreateDialog/types.ts @@ -4,7 +4,9 @@ import type { ReactNode } from "react"; export type StepProps = { active: boolean; loading: boolean; + eventId?: number; setLoading: (value: boolean) => void; + setEventId: (value?: number) => void; setError: (text: string | null) => void; setComplete: (value: boolean) => void; setActions: (actions: ReactNode[]) => void; From 3a0cbb4cdb6ebede4bc29e6e432d6a4a925eca17 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 7 Sep 2023 19:32:11 +0200 Subject: [PATCH 070/135] add basic event card template --- src/components/ContentWrapper.tsx | 2 +- src/components/EventCard.tsx | 58 +++++++++++++++++++++++++++++++ src/components/EventGrid.tsx | 23 ++++++++++++ src/layouts/MainLayout.tsx | 2 +- src/pages/OrganizerPage.tsx | 2 ++ 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 src/components/EventCard.tsx create mode 100644 src/components/EventGrid.tsx diff --git a/src/components/ContentWrapper.tsx b/src/components/ContentWrapper.tsx index c99c0cf..6f6f9fe 100644 --- a/src/components/ContentWrapper.tsx +++ b/src/components/ContentWrapper.tsx @@ -18,7 +18,7 @@ export function ContentWrapper(props: ContentWrapperProps) { const { children, title, tooltip, secondary, isLoading, isAuthorized } = props; return ( - + + + + + + {event.title} + + + Event #{event.id} + + + {event.description} + + + + + + + + + + + + ); +} + +export default EventCard; diff --git a/src/components/EventGrid.tsx b/src/components/EventGrid.tsx new file mode 100644 index 0000000..97129a6 --- /dev/null +++ b/src/components/EventGrid.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; + +import { Grid } from "@mui/material"; +import { Event } from "types"; +import EventCard from "./EventCard"; + +type EventGridProps = { + events: Event[]; +}; + +function EventGrid({ events }: EventGridProps) { + return ( + + {events.map((event, index) => ( + + + + ))} + + ); +} + +export default EventGrid; diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index d45746f..7f3795f 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -27,7 +27,7 @@ function MainLayout(props: any) { display: "flex", justifyContent: "center", }} - maxWidth="md" + maxWidth="lg" > diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index 9606fc3..4af2364 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -12,6 +12,7 @@ import { activeDialogAtom } from "states/atoms"; import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; +import EventGrid from "components/EventGrid"; function OrganizerPage() { const { isActive, networkId } = useWeb3(); @@ -111,6 +112,7 @@ function OrganizerPage() { } > + ); } From a20f5d5f1c1a1f28a691511bd5a60654d68edc08 Mon Sep 17 00:00:00 2001 From: Riku Date: Fri, 8 Sep 2023 18:31:20 +0200 Subject: [PATCH 071/135] add payment API --- src/apis/event.ts | 6 ++---- src/apis/index.ts | 2 ++ src/apis/payment.ts | 25 +++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 src/apis/payment.ts diff --git a/src/apis/event.ts b/src/apis/event.ts index 43100fa..f9be870 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -108,12 +108,10 @@ export type inviteData = { attendeeWalletAddresses: string[]; }; -export type inviteResult = boolean; - export const invite = async ( jwt: string, data: inviteData -): Promise => { +): Promise => { const response = await axios.post( new URL("/event/invite", config.apiURL).toString(), data, @@ -126,7 +124,7 @@ export const invite = async ( } ); - return response.data.result as inviteResult; + return response.data.result as boolean; }; export type getInfoResult = Event | undefined; diff --git a/src/apis/index.ts b/src/apis/index.ts index 3dfa1bd..aefed9a 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -3,6 +3,7 @@ import * as auth from "./auth"; import * as event from "./event"; import * as events from "./events"; import * as offers from "./offers"; +import * as payment from "./payment" import * as user from "./user"; import * as users from "./users"; @@ -12,6 +13,7 @@ export const API = { event, events, offers, + payment, user, users, }; diff --git a/src/apis/payment.ts b/src/apis/payment.ts new file mode 100644 index 0000000..17572e0 --- /dev/null +++ b/src/apis/payment.ts @@ -0,0 +1,25 @@ +import axios from "axios"; +import config from "config"; + +import type { NetworkIdentifier } from "types"; + +export type checkData = { + networkId: NetworkIdentifier; + txHash: string; +}; + +export const check = async (jwt: string, data: checkData): Promise => { + const response = await axios.post( + new URL("/payment/check", config.apiURL).toString(), + data, + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + } + ); + + return response.data.result as boolean; +}; From 37ef584b052afaaa8b06ec7128bf5bedc99ffd6d Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 14 Sep 2023 08:01:05 +0200 Subject: [PATCH 072/135] add event cancel API --- src/apis/event.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/apis/event.ts b/src/apis/event.ts index f9be870..d7cdb60 100644 --- a/src/apis/event.ts +++ b/src/apis/event.ts @@ -62,6 +62,29 @@ export const create = async ( return response.data.result as createResult; }; +export type cancelData = { + eventId: number; +}; + +export const cancel = async ( + jwt: string, + data: cancelData +): Promise => { + const response = await axios.post( + new URL("/event/cancel", config.apiURL).toString(), + data, + { + headers: { + Authorization: `Bearer ${jwt}`, + }, + responseType: "json", + timeout: config.timeout, + } + ); + + return response.data.result as boolean; +}; + export type joinData = { maskedEventId: string; createOffer: boolean; @@ -87,7 +110,10 @@ export type claimData = { maskedEventId: string; }; -export const claim = async (jwt: string, data: claimData): Promise => { +export const claim = async ( + jwt: string, + data: claimData +): Promise => { const response = await axios.post( new URL("/event/claim", config.apiURL).toString(), data, From 08f8fe9b295d03c96b0cf748b4963c4fc4cb46e1 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 14 Sep 2023 17:47:51 +0200 Subject: [PATCH 073/135] add info box --- package.json | 2 ++ src/components/InfoBox.tsx | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/components/InfoBox.tsx diff --git a/package.json b/package.json index 95a99f4..1ceeec3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "qrcode.react": "^3.1.0", "react": "^18.2.0", "react-apexcharts": "^1.4.1", + "react-copy-to-clipboard": "^5.1.0", "react-device-detect": "^2.2.3", "react-dom": "^18.2.0", "react-hook-form": "^7.45.0", @@ -68,6 +69,7 @@ "@types/jest": "^27.0.1", "@types/node": "^16.7.13", "@types/react": "^18.0.0", + "@types/react-copy-to-clipboard": "^5.0.4", "@types/react-dom": "^18.0.0", "@types/react-window": "^1.8.5", "dotenv-webpack": "^8.0.1", diff --git a/src/components/InfoBox.tsx b/src/components/InfoBox.tsx new file mode 100644 index 0000000..884cc03 --- /dev/null +++ b/src/components/InfoBox.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +import { + Box, + Alert, + AlertTitle, + type SxProps, + type Theme, +} from "@mui/material"; + +type InfoBoxProps = { + sx?: SxProps; + text: React.ReactNode; +}; + +function InfoBox({ sx, text }: InfoBoxProps) { + return ( + + + Info + {text} + + + ); +} + +export default InfoBox; From 92bd0516082ad7aced95594c97bc00f4f3742e78 Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 14 Sep 2023 17:48:41 +0200 Subject: [PATCH 074/135] update connector to return txhash --- src/components/ClaimDialog.tsx | 4 ++-- src/components/JoinDialog.tsx | 5 +++-- src/connectors/gem.ts | 10 ++++----- src/connectors/provider.ts | 4 ++-- src/connectors/xumm.ts | 40 ++++++++++++++++++++++++---------- src/pages/ClaimPage.tsx | 5 +++-- 6 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/components/ClaimDialog.tsx b/src/components/ClaimDialog.tsx index 3a03ff2..f481455 100644 --- a/src/components/ClaimDialog.tsx +++ b/src/components/ClaimDialog.tsx @@ -73,9 +73,9 @@ function ClaimDialog() { } ); const result = await provider.acceptOffer(offer.offerIndex); - const success = await result.resolved; + const txHash = await result.resolved; - if (success) { + if (txHash) { enqueueSnackbar("Claim successful", { variant: "success", }); diff --git a/src/components/JoinDialog.tsx b/src/components/JoinDialog.tsx index 7da21f3..6a7bac0 100644 --- a/src/components/JoinDialog.tsx +++ b/src/components/JoinDialog.tsx @@ -74,9 +74,10 @@ function JoinDialog() { autoHideDuration: 30000, } ); - const success = await provider.acceptOffer(offer.offerIndex); + const result = await provider.acceptOffer(offer.offerIndex); + const txHash = await result.resolved; - if (success) { + if (txHash) { enqueueSnackbar("Claim successful", { variant: "success", }); diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index f2af7e2..fe18882 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -39,7 +39,7 @@ export class GemWalletProvider extends Provider { } return { - resolved: Promise.resolve(Boolean(response.result?.hash)), + resolved: Promise.resolve(response.result?.hash), }; } @@ -55,17 +55,17 @@ export class GemWalletProvider extends Provider { } return { - resolved: Promise.resolve(Boolean(response.result?.hash)), + resolved: Promise.resolve(response.result?.hash), }; } public async sendPayment( - amount: string, + value: string, destination: string, memo?: string ): Promise { const response = await Gem.sendPayment({ - amount: amount, + amount: value, destination: destination, memos: memo ? [ @@ -84,7 +84,7 @@ export class GemWalletProvider extends Provider { } return { - resolved: Promise.resolve(Boolean(response.result?.hash)), + resolved: Promise.resolve(response.result?.hash), }; } diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts index 162e852..fb86327 100644 --- a/src/connectors/provider.ts +++ b/src/connectors/provider.ts @@ -3,7 +3,7 @@ export type AuthData = { }; export type ProviderRequestResult = { - resolved: Promise; + resolved: Promise; uuid?: string; } @@ -11,7 +11,7 @@ export abstract class Provider { public abstract acceptOffer(offerIndex: string): Promise; public abstract setAccount(minterAddress: string): Promise; public abstract sendPayment( - amount: string, + value: string, destination: string, memo?: string ): Promise; diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 58c669d..842d83c 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -11,9 +11,16 @@ import { } from "connectors/provider"; import { ConnectorType, NetworkIdentifier } from "types"; -async function _wrap(promise?: Promise): Promise { +type CallbackData = { + signed: boolean; + txHash: string; +}; + +async function _wrap( + promise?: Promise +): Promise { const result = await promise; - return !!result; + return result?.signed ? result.txHash : undefined; } export class XummWalletProvider extends Provider { @@ -31,11 +38,16 @@ export class XummWalletProvider extends Provider { private async submitPayload( tx: SdkTypes.XummJsonTransaction ): Promise { - const callback = async (event: SdkTypes.SubscriptionCallbackParams) => { + const callback = async ( + event: SdkTypes.SubscriptionCallbackParams + ): Promise => { console.debug("callback", event); if (event.data?.payload_uuidv4) { // set the deferred promise value and close the subscription - return event.data?.signed; + return { + signed: event.data.signed, + txHash: event.data.txid, + }; } }; @@ -44,7 +56,7 @@ export class XummWalletProvider extends Provider { txjson: tx, options: { return_url: { - app: document.location.href, + app: undefined, // TODO document.location.href, web: undefined, }, }, @@ -53,7 +65,7 @@ export class XummWalletProvider extends Provider { ); this.pendingPayloads.push(subscription.created.uuid); - console.log("sub", subscription); + return subscription; } @@ -64,7 +76,9 @@ export class XummWalletProvider extends Provider { }); return { - resolved: _wrap(subscription.resolved), + resolved: _wrap( + subscription.resolved as Promise | undefined + ), uuid: subscription.created.uuid, }; } @@ -79,19 +93,21 @@ export class XummWalletProvider extends Provider { }); return { - resolved: _wrap(subscription.resolved), + resolved: _wrap( + subscription.resolved as Promise | undefined + ), uuid: subscription.created.uuid, }; } public async sendPayment( - amount: string, + value: string, destination: string, memo?: string ): Promise { const subscription = await this.submitPayload({ TransactionType: "Payment", - Amount: amount, + Amount: value, Destination: destination, Memos: memo ? [ @@ -107,7 +123,9 @@ export class XummWalletProvider extends Provider { }); return { - resolved: _wrap(subscription.resolved), + resolved: _wrap( + subscription.resolved as Promise | undefined + ), uuid: subscription.created.uuid, }; } diff --git a/src/pages/ClaimPage.tsx b/src/pages/ClaimPage.tsx index 1c87613..0e3289e 100644 --- a/src/pages/ClaimPage.tsx +++ b/src/pages/ClaimPage.tsx @@ -294,9 +294,10 @@ function ClaimPage() { setUuid(result.uuid); - const success = await result.resolved; + const txHash = await result.resolved; + console.log("txHash", txHash); - if (success) { + if (txHash) { enqueueSnackbar("Claim successful", { variant: "success", }); From d1d633d9c379301e3fb94adf5152a933ff778b0e Mon Sep 17 00:00:00 2001 From: Riku Date: Thu, 14 Sep 2023 17:50:56 +0200 Subject: [PATCH 075/135] basic working create flow --- .../CreateDialog/AuthorizationStep.tsx | 107 +++++---- src/components/CreateDialog/CreateDialog.tsx | 212 ++++++++--------- src/components/CreateDialog/CreationStep.tsx | 127 +++++----- src/components/CreateDialog/PaymentStep.tsx | 219 +++++++++++++++--- src/components/CreateDialog/SummaryStep.tsx | 209 ++++++++++++++--- src/components/CreateDialog/types.ts | 1 - src/types.ts | 10 +- 7 files changed, 604 insertions(+), 281 deletions(-) diff --git a/src/components/CreateDialog/AuthorizationStep.tsx b/src/components/CreateDialog/AuthorizationStep.tsx index fa5a569..59dfd2d 100644 --- a/src/components/CreateDialog/AuthorizationStep.tsx +++ b/src/components/CreateDialog/AuthorizationStep.tsx @@ -21,11 +21,11 @@ function AuthorizationStep({ setError, setComplete, setActions, - close, }: StepProps) { const { provider, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); + const [count, setCount] = React.useState(0); // const [loading, setLoading] = React.useState(false); const { enqueueSnackbar } = useSnackbar(); @@ -37,8 +37,10 @@ function AuthorizationStep({ // update parent state React.useEffect(() => { - setComplete(Boolean(data?.isConfigured)); - }, [data]); + if (active) { + setComplete(Boolean(data?.isConfigured)); + } + }, [active, data]); // fetch authorized minter info React.useEffect(() => { @@ -78,6 +80,7 @@ function AuthorizationStep({ } }; + // TODO consider adding && active if (isAuthorized) { load(); } else { @@ -87,53 +90,59 @@ function AuthorizationStep({ return () => { mounted = false; }; - }, [isAuthorized, networkId, jwt]); + }, [isAuthorized, networkId, jwt, count]); - // TODO - const handleConfirm = async (event: React.MouseEvent) => { - setLoading(true); - try { - if (provider && data?.walletAddress) { - const result = await provider.setAccount(data.walletAddress); - const success = await result.resolved; - - console.log(success); - // TODO force redownload minter - } - } catch (err) { - console.debug(err); - if (axios.isAxiosError(err)) { - enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { - variant: "error", - }); - } else { - enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { - variant: "error", - }); + const handleConfirm = React.useCallback( + async (event: React.MouseEvent) => { + setLoading(true); + try { + if (provider && data?.walletAddress) { + const result = await provider.setAccount(data.walletAddress); + const txHash = await result.resolved; + + console.log(txHash); + // TODO force redownload minter + setCount((c) => c + 1); + + // TODO reset local state? + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); } - } finally { - setLoading(false); + }, + [data?.walletAddress, provider, enqueueSnackbar, setLoading] + ); + + // set actions + React.useEffect(() => { + if (active) { + setActions([ + , + ]); + } else { + setActions([]); } - }; - - // // set actions - // React.useEffect(() => { - // console.log("setting actions", active); - // if (active) { - // setActions([ - // , - // ]); - // } else { - // setActions([]); - // } - // }, [active, loading, isAuthorized, data, handleConfirm]); + }, [active, loading, isAuthorized, data, handleConfirm]); // TODO display "checking minter status" while we load the status with spinner // TODO @@ -152,7 +161,7 @@ function AuthorizationStep({ )} - + */} ) : null; } diff --git a/src/components/CreateDialog/CreateDialog.tsx b/src/components/CreateDialog/CreateDialog.tsx index 3915bf0..6576cc8 100644 --- a/src/components/CreateDialog/CreateDialog.tsx +++ b/src/components/CreateDialog/CreateDialog.tsx @@ -1,40 +1,26 @@ import React from "react"; -import axios from "axios"; import { useAtom } from "jotai"; -import { useSnackbar } from "notistack"; import { create } from "zustand"; -import { persist, createJSONStorage } from "zustand/middleware"; import { Box, Button, - Stepper, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + IconButton, Step, - StepLabel, StepContent, - Paper, + StepLabel, + Stepper, Typography, } from "@mui/material"; - -import Checkbox from "@mui/material/Checkbox"; import CloseIcon from "@mui/icons-material/Close"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import FormControlLabel from "@mui/material/FormControlLabel"; -import FormGroup from "@mui/material/FormGroup"; -import IconButton from "@mui/material/IconButton"; -import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import CircularProgress from "@mui/material/CircularProgress"; -import { DatePicker } from "@mui/x-date-pickers/DatePicker"; -import API from "apis"; -import { useWeb3 } from "connectors/context"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; -import { useAuth } from "components/AuthContext"; import AuthorizationStep from "./AuthorizationStep"; import CreationStep from "./CreationStep"; import SummaryStep from "./SummaryStep"; @@ -86,10 +72,6 @@ const stepInfo: StepInfo[] = [ }, ]; -// TODO how to solve loading icon ? -// just create react node, easiest ? -// type DialogAction = { label: string; callback: () => void; disabled?: boolean }; - type State = { loading: boolean; activeStep: Steps; @@ -159,15 +141,9 @@ const useStore = create()((set, get) => ({ })); function CreateDialog() { - const { account, networkId } = useWeb3(); - const { isAuthenticated, jwt, permissions } = useAuth(); - // const [state, dispatch] = React.useReducer(); const state = useStore(); const [open, setOpen] = React.useState(false); - // const [loading, setLoading] = React.useState(false); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); - // const [activeStep, setActiveStep] = React.useState(0); - const { enqueueSnackbar } = useSnackbar(); React.useEffect(() => { setOpen(activeDialog.type === DialogIdentifier.DIALOG_CREATE); @@ -179,28 +155,24 @@ function CreateDialog() { // update active step React.useEffect(() => { + state.setActiveStep(Steps.SUMMARY); + return; + for (const step of stepInfo) { if (!state.complete[step.id]) { state.setActiveStep(step.id); break; } } - - // if (!state.complete[Steps.AUTHORIZATION]) { - // state.setActiveStep(Steps.AUTHORIZATION); - // return; - // } else if (!state.complete[Steps.CREATION]) { - // state.setActiveStep(Steps.CREATION); - // return; - // } - // TODO reset - // TODO does this even work (as expected)? }, [state.complete]); const handleClose = (event?: {}, reason?: string) => { if (reason === "backdropClick") { return; } + // TODO reset dialog (including every step) + // state.reset(); + // setOpen(false); // doesnt fix strange change while close setActiveDialog({}); }; @@ -243,85 +215,91 @@ function CreateDialog() { flexDirection: "row", }} > - - - {stepInfo.map((step, index) => ( - - - {step.label} - - - {step.description} - - - ))} - - - {/* TODO loop this ? */} - state.setError(Steps.AUTHORIZATION, text)} - setComplete={(value) => state.setComplete(Steps.AUTHORIZATION, value)} - setActions={(actions) => - state.setDialogActions(Steps.AUTHORIZATION, actions) - } - close={handleClose} - /> - state.setError(Steps.CREATION, text)} - setComplete={(value) => state.setComplete(Steps.CREATION, value)} - setActions={(actions) => - state.setDialogActions(Steps.CREATION, actions) - } - close={handleClose} - /> - state.setError(Steps.PAYMENT, text)} - setComplete={(value) => state.setComplete(Steps.PAYMENT, value)} - setActions={(actions) => - state.setDialogActions(Steps.PAYMENT, actions) - } - close={handleClose} - /> - state.setError(Steps.SUMMARY, text)} - setComplete={(value) => state.setComplete(Steps.SUMMARY, value)} - setActions={(actions) => - state.setDialogActions(Steps.SUMMARY, actions) - } - close={handleClose} - /> + + + + + {stepInfo.map((step, index) => ( + + + {step.label} + + + + {step.description} + + + + ))} + + + + + state.setError(Steps.AUTHORIZATION, text)} + setComplete={(value) => + state.setComplete(Steps.AUTHORIZATION, value) + } + setActions={(actions) => + state.setDialogActions(Steps.AUTHORIZATION, actions) + } + /> + state.setError(Steps.CREATION, text)} + setComplete={(value) => state.setComplete(Steps.CREATION, value)} + setActions={(actions) => + state.setDialogActions(Steps.CREATION, actions) + } + /> + state.setError(Steps.PAYMENT, text)} + setComplete={(value) => state.setComplete(Steps.PAYMENT, value)} + setActions={(actions) => + state.setDialogActions(Steps.PAYMENT, actions) + } + /> + state.setError(Steps.SUMMARY, text)} + setComplete={(value) => state.setComplete(Steps.SUMMARY, value)} + setActions={(actions) => + state.setDialogActions(Steps.SUMMARY, actions) + } + /> + + - {state.dialogActions[state.activeStep].map((x) => x)} + {state.dialogActions[state.activeStep].map((button, index) => ( + {button} + ))} ); diff --git a/src/components/CreateDialog/CreationStep.tsx b/src/components/CreateDialog/CreationStep.tsx index bd73038..b7c9965 100644 --- a/src/components/CreateDialog/CreationStep.tsx +++ b/src/components/CreateDialog/CreationStep.tsx @@ -20,16 +20,13 @@ import { } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Box } from "@mui/material"; -import Stack from "@mui/material/Stack"; -import TextField from "@mui/material/TextField"; -import Typography from "@mui/material/Typography"; +import { Box, Button, Stack, TextField } from "@mui/material"; + import CircularProgress from "@mui/material/CircularProgress"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import API from "apis"; import { useWeb3 } from "connectors/context"; -import { activeDialogAtom } from "states/atoms"; import { useAuth } from "components/AuthContext"; import { StepProps } from "./types"; @@ -111,9 +108,9 @@ function CreationStep({ eventId, setLoading, setEventId, + setActions, setError, setComplete, - close, }: StepProps) { const { networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); @@ -138,58 +135,80 @@ function CreationStep({ // update parent state React.useEffect(() => { - setComplete(Boolean(eventId)); - }, [eventId]); - - // const handleClose = (event: {}, reason?: string) => { - // if (reason === "backdropClick") { - // return; - // } - // setActiveDialog({}); - // }; + if (active) { + setComplete(Boolean(eventId)); + } + }, [active, eventId]); - // const handleCancel = (event: React.MouseEvent) => { - // reset(); - // setActiveDialog({}); - // }; + const onSubmit: SubmitHandler = React.useCallback( + async (values) => { + setLoading(true); + try { + if (networkId && isAuthorized && jwt) { + const result = await API.event.create(jwt, { + networkId: networkId, + tokenCount: values.tokenCount, + title: values.title, + description: values.description, + location: values.location, + imageUrl: values.url, + dateStart: values.dateStart!, + dateEnd: values.dateEnd!, + isManaged: !values.isPublic, + }); + console.debug("CreateResult", result); + enqueueSnackbar(`Creation successful: Event #${result.eventId}`, { + variant: "success", + }); - const onSubmit: SubmitHandler = async (values) => { - setLoading(true); - try { - if (networkId && isAuthorized && jwt) { - const result = await API.event.create(jwt, { - networkId: networkId, - tokenCount: values.tokenCount, - title: values.title, - description: values.description, - location: values.location, - imageUrl: values.url, - dateStart: values.dateStart!, - dateEnd: values.dateEnd!, - isManaged: !values.isPublic, - }); - console.debug("CreateResult", result); - enqueueSnackbar(`Creation successful: Event #${result.eventId}`, { - variant: "success", - }); - reset(); + setEventId(result.eventId); + // TODO + // reset(); + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Creation failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Creation failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + // setActiveDialog({}); } - } catch (err) { - console.debug(err); - if (axios.isAxiosError(err)) { - enqueueSnackbar(`Creation failed: ${err.response?.data.error}`, { - variant: "error", - }); - } else { - enqueueSnackbar(`Creation failed: ${(err as Error).message}`, { - variant: "error", - }); - } - } finally { - setLoading(false); - // setActiveDialog({}); + }, + [ + enqueueSnackbar, + isAuthorized, + jwt, + networkId, + // reset, + setLoading, + setEventId, + ] + ); + + // set actions + React.useEffect(() => { + if (active) { + setActions([ + , + ]); + } else { + setActions([]); } - }; + }, [active, loading, isAuthorized, isValid, handleSubmit, onSubmit]); return active ? ( diff --git a/src/components/CreateDialog/PaymentStep.tsx b/src/components/CreateDialog/PaymentStep.tsx index a02227a..1906f02 100644 --- a/src/components/CreateDialog/PaymentStep.tsx +++ b/src/components/CreateDialog/PaymentStep.tsx @@ -3,7 +3,7 @@ import axios from "axios"; import { useSnackbar } from "notistack"; -import { Box, Button } from "@mui/material"; +import { Box, Button, Link } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; @@ -12,48 +12,215 @@ import { useWeb3 } from "connectors/context"; import { useAuth } from "components/AuthContext"; import Loader from "components/Loader"; import { StepProps } from "./types"; -import { Minter } from "types"; +import { Event } from "types"; -function PaymentStep({ active, setError, setComplete, close }: StepProps) { - const { provider, networkId } = useWeb3(); +function PaymentStep({ + active, + loading, + eventId, + setError, + setComplete, + setLoading, + setActions, +}: StepProps) { + const { isActive, provider, networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); - const [data, setData] = React.useState(); - const [loading, setLoading] = React.useState(false); + const [data, setData] = React.useState(); + const [count, setCount] = React.useState(0); const { enqueueSnackbar } = useSnackbar(); const isAuthorized = React.useMemo(() => { return isAuthenticated && permissions.includes("organizer"); }, [isAuthenticated, permissions]); + // update parent state + React.useEffect(() => { + if (active) { + setComplete(Boolean(data?.accounting?.depositTxHash)); + } + }, [active, data?.accounting]); + // TODO only load event info, if active, if not active reset + // fetch authorized minter info + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (eventId && jwt) { + const event = await API.event.getInfo(eventId, jwt); + + // TODO + console.log(event); + + if (mounted) { + setData(event); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load stats data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load stats data", { + variant: "error", + }); + } + } + }; + + // TODO only if active, but don't reset? + if (isAuthorized && active) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [active, eventId, isAuthorized, jwt, count]); + + const handleConfirm = React.useCallback( + async (event: React.MouseEvent) => { + setLoading(true); + try { + if (jwt && networkId && provider && eventId && data?.accounting) { + const value = ( + BigInt(data.accounting.depositReserveValue) + + BigInt(data.accounting.depositFeeValue) + ).toString(); + const result = await provider.sendPayment( + value, + data.accounting.depositAddress, + `deposit event ${eventId}` + ); + const txHash = await result.resolved; + console.log(txHash); + + if (!txHash) { + // TODO + throw Error("Payment failed"); + } + + const success = await API.payment.check(jwt, { + networkId: networkId, + txHash: txHash, + }); + + console.log(success); + + // TODO handle fail + + // TODO force re-download event + setCount((c) => c + 1); + // TODO reset local state? + } + + // DEBUG + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + } + }, + [eventId, jwt, networkId, data, provider, enqueueSnackbar, setLoading] + ); + + // set actions + React.useEffect(() => { + if (active) { + setActions([ + , + ]); + } else { + setActions([]); + } + }, [ + active, + loading, + isActive, + isAuthorized, + data?.accounting, + handleConfirm, + ]); + // TODO display "checking minter status" while we load the status with spinner return active ? (

    isAuthorized: {isAuthorized ? "true" : "false"}

    - {data ? ( -
    -

    isConfigured: {data.isConfigured ? "true" : "false"}

    -

    minter address: {data.walletAddress}

    -
    + {data?.accounting ? ( + + +

    Reserve Deposit Breakdown

    +

    For tokenCount: {data.tokenCount}

    + + Base Owner Reserve = 2 XRP NFTokenPage Reserve = 2 * + ([tokenCount]/16) = 8.33 = 10 XRP (Reserves round up) NFTokenOffer + Reserve = 2 * [tokenCount] = 200 XRP Total Deposit = TODO + + + Details here + + + Details here + +
    + + +

    txHash: {data.accounting.depositTxHash}

    +

    Deposit Address: {data.accounting.depositAddress}

    +

    Deposit Reserve: {data.accounting.depositReserveValue}

    +

    Deposit Fee: {data.accounting.depositFeeValue}

    +

    + Deposit Total:{" "} + {( + BigInt(data.accounting.depositReserveValue) + + BigInt(data.accounting?.depositFeeValue) + ).toString()} +

    +

    Transaction Cost: ~15 drops

    +
    +
    ) : ( -
    - -
    + )} - - -
    ) : null; } diff --git a/src/components/CreateDialog/SummaryStep.tsx b/src/components/CreateDialog/SummaryStep.tsx index de0864f..f049654 100644 --- a/src/components/CreateDialog/SummaryStep.tsx +++ b/src/components/CreateDialog/SummaryStep.tsx @@ -2,56 +2,205 @@ import React from "react"; import axios from "axios"; import { useSnackbar } from "notistack"; +import { QRCodeCanvas } from "qrcode.react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { saveAs } from "file-saver"; -import { Box, Button } from "@mui/material"; +import { Box, IconButton, Tooltip, Button } from "@mui/material"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import CircularProgress from "@mui/material/CircularProgress"; import API from "apis"; -import { useWeb3 } from "connectors/context"; import { useAuth } from "components/AuthContext"; import Loader from "components/Loader"; import { StepProps } from "./types"; -import { Minter } from "types"; +import InfoBox from "components/InfoBox"; -function SummaryStep({ active, setError, setComplete, close }: StepProps) { - const { provider, networkId } = useWeb3(); +function SummaryStep({ + active, + loading, + eventId, + setLoading, + setError, + setComplete, + setActions, +}: StepProps) { const { isAuthenticated, jwt, permissions } = useAuth(); - const [data, setData] = React.useState(); - const [loading, setLoading] = React.useState(false); + const [data, setData] = React.useState(); + const [copied, setCopied] = React.useState(false); const { enqueueSnackbar } = useSnackbar(); const isAuthorized = React.useMemo(() => { return isAuthenticated && permissions.includes("organizer"); }, [isAuthenticated, permissions]); - // TODO display "checking minter status" while we load the status with spinner - return active ? ( - -

    isAuthorized: {isAuthorized ? "true" : "false"}

    + // update parent state + React.useEffect(() => { + if (active) { + setComplete(Boolean(data)); + } + }, [active, data]); + + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (jwt && eventId) { + const masked = await API.event.getLink(jwt, eventId); + if (mounted) { + setData(`${window.location.origin}/claim/${masked}`); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load event link: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar("Failed to load event link", { + variant: "error", + }); + } + } + }; + + if (isAuthorized && active) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [active, eventId, jwt, isAuthorized]); + const handleConfirm = React.useCallback( + async (event: React.MouseEvent) => { + // TODO reset local state + // setData(undefined); + }, + [] + ); + + const handleDownload = React.useCallback(() => { + const canvas = document.getElementById("qrcode") as HTMLCanvasElement; + if (canvas) { + canvas.toBlob((blob) => { + saveAs(blob!, "qr.png"); + }); + } + }, []); + + // set actions + React.useEffect(() => { + if (active) { + setActions([ + , + ]); + } else { + setActions([]); + } + }, [active, loading, data, handleConfirm, handleDownload]); + + return active ? ( + {data ? ( -
    -

    isConfigured: {data.isConfigured ? "true" : "false"}

    -

    minter address: {data.walletAddress}

    -
    + + + + + theme.palette.grey[100], + border: "1px solid", + borderRadius: "4px", + borderColor: (theme) => theme.palette.grey[300], + padding: "0.75rem", + marginTop: "1rem", + position: "relative", + }} + > + + {data} + + + setCopied(true)}> + setCopied(false)} + componentsProps={{ + tooltip: { + sx: { + bgcolor: (theme) => + copied ? theme.palette.success.light : null, + }, + }, + }} + > + + + + + + + + + ) : ( -
    - -
    + )} - - -
    ) : null; } diff --git a/src/components/CreateDialog/types.ts b/src/components/CreateDialog/types.ts index 5956d71..544559a 100644 --- a/src/components/CreateDialog/types.ts +++ b/src/components/CreateDialog/types.ts @@ -10,5 +10,4 @@ export type StepProps = { setError: (text: string | null) => void; setComplete: (value: boolean) => void; setActions: (actions: ReactNode[]) => void; - close: (event?: {}, reason?: string) => void; }; diff --git a/src/types.ts b/src/types.ts index 9119dc0..cf234fa 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,8 +30,9 @@ export enum EventStatus { PENDING, PAID, ACTIVE, - CLOSED, CANCELED, + CLOSED, + REFUNDED, } export type User = { @@ -49,10 +50,11 @@ export type User = { export type Accounting = { id: number; - depositReserveValue: number; - depositFeeValue: number; + depositAddress: string; + depositReserveValue: string; + depositFeeValue: string; depositTxHash?: string; - refundValue?: number; + refundValue?: string; refundTxHash?: string; accumulatedTxFees: number; eventId: Event["id"]; From 1908855f76b254daee50d2d1ea30c83fb844681c Mon Sep 17 00:00:00 2001 From: Riku Date: Fri, 15 Sep 2023 14:19:05 +0200 Subject: [PATCH 076/135] add cancel dialog - hide attendee page - only use event table for admin view --- src/components/CancelDialog.tsx | 143 ++++++++++++++++++++++++++++++++ src/components/EventTable.tsx | 82 +++++++++--------- src/components/Header.tsx | 2 +- src/layouts/AdminLayout.tsx | 4 +- src/layouts/MainLayout.tsx | 6 +- src/pages/AttendeePage.tsx | 2 +- src/pages/OrganizerPage.tsx | 18 ---- src/types.ts | 1 + 8 files changed, 196 insertions(+), 62 deletions(-) create mode 100644 src/components/CancelDialog.tsx diff --git a/src/components/CancelDialog.tsx b/src/components/CancelDialog.tsx new file mode 100644 index 0000000..35f586a --- /dev/null +++ b/src/components/CancelDialog.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import axios from "axios"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; + +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogContentText from "@mui/material/DialogContentText"; +import DialogTitle from "@mui/material/DialogTitle"; +import IconButton from "@mui/material/IconButton"; +import CloseIcon from "@mui/icons-material/Close"; +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { activeDialogAtom } from "states/atoms"; +import { DialogIdentifier } from "types"; +import { useAuth } from "components/AuthContext"; + +type CancelDialogData = Record; + +function CancelDialog() { + const { provider, account } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [open, setOpen] = React.useState(false); + const [data, setData] = React.useState(); + const [loading, setLoading] = React.useState(false); + const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return ( + isAuthenticated && + ["organizer", "admin"].some((x) => permissions.includes(x)) + ); + }, [isAuthenticated, permissions]); + + React.useEffect(() => { + setOpen(activeDialog.type === DialogIdentifier.DIALOG_CANCEL); + setData(activeDialog.data); + }, [activeDialog]); + + const handleClose = (event: {}, reason?: string) => { + if (reason === "backdropClick") { + return; + } + setData(undefined); + setActiveDialog({}); + }; + + const handleCancel = (event: React.MouseEvent) => { + setData(undefined); + setActiveDialog({}); + }; + + const handleConfirm = async (event: React.MouseEvent) => { + setLoading(true); + try { + if (data?.eventId && jwt) { + const success = await API.event.cancel(jwt, { + eventId: data.eventId, + }); + console.debug("result", success); + + if (!success) { + enqueueSnackbar(`Event cancelation failed`, { + variant: "error", + }); + } + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`Claim failed: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`Claim failed: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + setData(undefined); + setActiveDialog({}); + } + }; + + return ( + + + Cancel Event #{data?.eventId} + + theme.palette.grey[500], + }} + size="small" + onClick={handleClose} + disabled={loading} + > + + + + + Are you sure you want to cancel event #{data?.eventId}? + + + + + + + + + ); +} + +export default CancelDialog; diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index 27dab18..d344d90 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -4,11 +4,12 @@ import { useSetAtom } from "jotai"; import clsx from "clsx"; import Link from "@mui/material/Link"; +import BlockIcon from "@mui/icons-material/Block"; import EventAvailableIcon from "@mui/icons-material/EventAvailable"; import FileCopyIcon from "@mui/icons-material/FileCopy"; import GroupAddIcon from "@mui/icons-material/GroupAdd"; import InfoIcon from "@mui/icons-material/Info"; -import QrCodeScannerIcon from '@mui/icons-material/QrCodeScanner'; +import QrCodeScannerIcon from "@mui/icons-material/QrCodeScanner"; import { GridActionsCellItem, GridColDef, @@ -36,8 +37,6 @@ export type EventTableRow = { export type EventTableProps = { rows: EventTableRow[]; - isOwner?: boolean; - isAttendee?: boolean; }; type GetterParamsType = GridValueGetterParams< @@ -46,9 +45,7 @@ type GetterParamsType = GridValueGetterParams< GridTreeNodeWithRender >; -export function EventTable(props: EventTableProps) { - const { rows, isOwner, isAttendee } = props; - const { isActive } = useWeb3(); +export function EventTable({ rows }: EventTableProps) { const setActiveDialog = useSetAtom(activeDialogAtom); const navigate = useNavigate(); @@ -92,6 +89,16 @@ export function EventTable(props: EventTableProps) { [setActiveDialog] ); + const handleCancel = React.useCallback( + (id: number) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_CANCEL, + data: { eventId: id }, + }); + }, + [setActiveDialog] + ); + const rowClassName = (params: GridRowClassNameParams) => { return clsx("data-table", { expired: params.row.status !== EventStatus.ACTIVE, @@ -140,30 +147,19 @@ export function EventTable(props: EventTableProps) { type: "date", width: 100, }, - ...(isAttendee - ? [ - { - field: "claimed", - headerName: "Claimed", - type: "boolean", - width: 80, - }, - ] - : [ - { - field: "slots", - headerName: "Slots", - type: "number", - width: 60, - valueGetter: ({ row }: GetterParamsType) => { - if (row.slotsTaken !== undefined) { - return `${row.slotsTaken}/${row.slotsTotal}`; - } else { - return row.slotsTotal; - } - }, - }, - ]), + { + field: "slots", + headerName: "Slots", + type: "number", + width: 60, + valueGetter: ({ row }: GetterParamsType) => { + if (row.slotsTaken !== undefined) { + return `${row.slotsTaken}/${row.slotsTotal}`; + } else { + return row.slotsTotal; + } + }, + }, { field: "actions", type: "actions", @@ -171,35 +167,45 @@ export function EventTable(props: EventTableProps) { minWidth: 45, getActions: (params) => { const active = params.row.status === EventStatus.ACTIVE; + const cancellable = [ + EventStatus.PENDING, + EventStatus.PAID, + EventStatus.ACTIVE, + ].includes(params.row.status); return [ } label="Add Participant" onClick={() => handleAdd(params.row.id)} - disabled={!(active && isActive && isOwner)} + disabled={!active} showInMenu />, } label="Create Link" onClick={() => handleLink(params.row.id)} - disabled={!(active && isActive && isOwner)} + disabled={!active} showInMenu />, } label="Join Event" onClick={() => handleJoin(params.row.id, params.row.title)} - disabled={!(active && isActive && !isAttendee)} + disabled={!active} showInMenu />, } label="Claim NFT" onClick={() => handleClaim(params.row.id)} - disabled={ - !(active && isActive && isAttendee && !params.row.claimed) - } + disabled={!active} + showInMenu + />, + } + label="Cancel Event" + onClick={() => handleCancel(params.row.id)} + disabled={!cancellable} showInMenu />, = React.useMemo( () => [ ["Home", "/", false], - ["Attendee", "/attendee", !(isActive && isAuthenticated)], + // ["Attendee", "/attendee", !(isActive && isAuthenticated)], ["Organizer", "/organizer", !(isActive && isOrganizer)], ["Admin", "/admin", !(isActive && isAdmin)], ...(config.debug diff --git a/src/layouts/AdminLayout.tsx b/src/layouts/AdminLayout.tsx index 508d283..c4aa20e 100644 --- a/src/layouts/AdminLayout.tsx +++ b/src/layouts/AdminLayout.tsx @@ -19,6 +19,7 @@ import QueryStatsIcon from "@mui/icons-material/QueryStats"; import { activeDialogAtom } from "states/atoms"; import Header from "components/Header"; +import CancelDialog from "components/CancelDialog"; // TODO remove const drawerWidth = 240; @@ -29,7 +30,7 @@ function AdminLayout() { const entries = [ { label: "Overview", icon: , to: "/admin/stats" }, - { label: "Users", icon: , to: "/admin/users" }, + { label: "Organizers", icon: , to: "/admin/users" }, { label: "Events", icon: , to: "/admin/events" }, ]; @@ -92,6 +93,7 @@ function AdminLayout() {
    +
    ); } diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 7f3795f..564df52 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -6,12 +6,13 @@ import Container from "@mui/material/Container"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; +import Header from "components/Header"; import AddDialog from "components/AddDialog"; +import CancelDialog from "components/CancelDialog"; import ClaimDialog from "components/ClaimDialog"; -import Header from "components/Header"; +import CreateDialog from "components/CreateDialog"; import JoinDialog from "components/JoinDialog"; import LinkDialog from "components/LinkDialog" -import CreateDialog from "components/CreateDialog"; import ProfileDialog from "components/ProfileDialog"; function MainLayout(props: any) { @@ -32,6 +33,7 @@ function MainLayout(props: any) { + diff --git a/src/pages/AttendeePage.tsx b/src/pages/AttendeePage.tsx index 22426c9..87c507e 100644 --- a/src/pages/AttendeePage.tsx +++ b/src/pages/AttendeePage.tsx @@ -88,7 +88,7 @@ function AttendeePage() { isLoading={!Boolean(data)} isAuthorized={isAuthenticated} > - ; + ; ); } diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index 4af2364..61b7ea3 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -9,7 +9,6 @@ import API from "apis"; import { useWeb3 } from "connectors/context"; import { DialogIdentifier, Event } from "types"; import { activeDialogAtom } from "states/atoms"; -import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; import EventGrid from "components/EventGrid"; @@ -74,22 +73,6 @@ function OrganizerPage() { }; }, [activeDialog, isActive, networkId, isAuthorized, jwt]); - const rows = React.useMemo(() => { - if (data) { - return data.map((event) => ({ - id: event.id, - status: event.status, - title: event.title, - dateStart: new Date(event.dateStart), - dateEnd: new Date(event.dateEnd), - slotsTaken: event.attendees?.length, - slotsTotal: event.tokenCount, - })); - } else { - return []; - } - }, [data]); - const handleClick = (event: React.MouseEvent) => { setActiveDialog({ type: DialogIdentifier.DIALOG_CREATE }); }; @@ -111,7 +94,6 @@ function OrganizerPage() { } > - ); diff --git a/src/types.ts b/src/types.ts index cf234fa..95ba71d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,6 @@ export enum DialogIdentifier { DIALOG_ADD, + DIALOG_CANCEL, DIALOG_CLAIM, DIALOG_CREATE, DIALOG_JOIN, From 3ad71a841094044c9a56e77c5c055eab78fa003d Mon Sep 17 00:00:00 2001 From: Riku Date: Sun, 17 Sep 2023 19:19:07 +0200 Subject: [PATCH 077/135] complete create dialog --- .../CreateDialog/AuthorizationStep.tsx | 95 ++++---- src/components/CreateDialog/ContentBox.tsx | 29 +++ src/components/CreateDialog/CreateDialog.tsx | 164 +++++-------- src/components/CreateDialog/PaymentStep.tsx | 217 ++++++++++++------ ...{CreationStep.tsx => RegistrationStep.tsx} | 45 ++-- src/components/CreateDialog/SummaryStep.tsx | 61 ++--- src/components/Debug.tsx | 2 + src/components/InfoBox.tsx | 9 +- 8 files changed, 337 insertions(+), 285 deletions(-) create mode 100644 src/components/CreateDialog/ContentBox.tsx rename src/components/CreateDialog/{CreationStep.tsx => RegistrationStep.tsx} (91%) diff --git a/src/components/CreateDialog/AuthorizationStep.tsx b/src/components/CreateDialog/AuthorizationStep.tsx index 59dfd2d..44b21f3 100644 --- a/src/components/CreateDialog/AuthorizationStep.tsx +++ b/src/components/CreateDialog/AuthorizationStep.tsx @@ -3,16 +3,19 @@ import axios from "axios"; import { useSnackbar } from "notistack"; -import { Box, Button } from "@mui/material"; +import { Box, Button, Typography } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; import API from "apis"; +import { Minter } from "types"; import { useWeb3 } from "connectors/context"; import { useAuth } from "components/AuthContext"; import Loader from "components/Loader"; +import Debug from "components/Debug"; import { StepProps } from "./types"; -import { Minter } from "types"; +import ContentBox from "./ContentBox"; +import InfoBox from "components/InfoBox"; function AuthorizationStep({ active, @@ -26,15 +29,12 @@ function AuthorizationStep({ const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); const [count, setCount] = React.useState(0); - // const [loading, setLoading] = React.useState(false); const { enqueueSnackbar } = useSnackbar(); const isAuthorized = React.useMemo(() => { return isAuthenticated && permissions.includes("organizer"); }, [isAuthenticated, permissions]); - // TODO set errors - // update parent state React.useEffect(() => { if (active) { @@ -53,35 +53,31 @@ function AuthorizationStep({ networkId: networkId, }); - // TODO - console.log(minter); - if (mounted) { + setError(null); setData(minter); } } } catch (err) { + const msg = "Failed to load minter data"; console.debug(err); if (mounted) { + setError(msg); setData(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar( - `Failed to load stats data: ${err.response?.data.error}`, - { - variant: "error", - } - ); + enqueueSnackbar(`${msg}: ${err.response?.data.error}`, { + variant: "error", + }); } else { - enqueueSnackbar("Failed to load stats data", { + enqueueSnackbar(`${msg}: ${(err as Error).message}`, { variant: "error", }); } } }; - // TODO consider adding && active - if (isAuthorized) { + if (active && isAuthorized) { load(); } else { setData(undefined); @@ -90,7 +86,7 @@ function AuthorizationStep({ return () => { mounted = false; }; - }, [isAuthorized, networkId, jwt, count]); + }, [active, isAuthorized, networkId, jwt, count]); const handleConfirm = React.useCallback( async (event: React.MouseEvent) => { @@ -100,20 +96,23 @@ function AuthorizationStep({ const result = await provider.setAccount(data.walletAddress); const txHash = await result.resolved; - console.log(txHash); - // TODO force redownload minter - setCount((c) => c + 1); + if (!txHash) { + throw Error("Transaction rejected"); + } - // TODO reset local state? + // force update authorized minter info + setCount((c) => c + 1); } } catch (err) { + const msg = "Failed to authorized account"; console.debug(err); + setError(msg); if (axios.isAxiosError(err)) { - enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { + enqueueSnackbar(`${msg}: ${err.response?.data.error}`, { variant: "error", }); } else { - enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { + enqueueSnackbar(`${msg}: ${(err as Error).message}`, { variant: "error", }); } @@ -121,7 +120,7 @@ function AuthorizationStep({ setLoading(false); } }, - [data?.walletAddress, provider, enqueueSnackbar, setLoading] + [data?.walletAddress, provider] ); // set actions @@ -144,34 +143,38 @@ function AuthorizationStep({ } }, [active, loading, isAuthorized, data, handleConfirm]); - // TODO display "checking minter status" while we load the status with spinner - // TODO return active ? ( -

    isAuthorized: {isAuthorized ? "true" : "false"}

    + + The platform mints and manages event NFTs on behalf of an organizer. + This requires a one-time on-chain authorization. + {data ? ( -
    -

    isConfigured: {data.isConfigured ? "true" : "false"}

    -

    minter address: {data.walletAddress}

    -
    + + + Platform Minter Address: + + + + {data.walletAddress} + + + ) : ( -
    - -
    + )} - {/* - */} +
    ) : null; } diff --git a/src/components/CreateDialog/ContentBox.tsx b/src/components/CreateDialog/ContentBox.tsx new file mode 100644 index 0000000..b1d50e6 --- /dev/null +++ b/src/components/CreateDialog/ContentBox.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +import { Box, SxProps, Theme } from "@mui/material"; + +type ContentBoxProps = { + sx?: SxProps; + children?: React.ReactNode; +}; + +function ContentBox({ sx, children }: ContentBoxProps) { + return ( + + {children} + + ); +} + +export default ContentBox; diff --git a/src/components/CreateDialog/CreateDialog.tsx b/src/components/CreateDialog/CreateDialog.tsx index 6576cc8..9950372 100644 --- a/src/components/CreateDialog/CreateDialog.tsx +++ b/src/components/CreateDialog/CreateDialog.tsx @@ -22,56 +22,18 @@ import CloseIcon from "@mui/icons-material/Close"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; import AuthorizationStep from "./AuthorizationStep"; -import CreationStep from "./CreationStep"; +import RegistrationStep from "./RegistrationStep"; import SummaryStep from "./SummaryStep"; import PaymentStep from "./PaymentStep"; enum Steps { + UNKNOWN, AUTHORIZATION, - CREATION, + REGISTRATION, PAYMENT, SUMMARY, } -type StepInfo = { - id: Steps; - label: string; - description: string; -}; - -const stepInfo: StepInfo[] = [ - { - id: Steps.AUTHORIZATION, - label: "Authorize Minter", - description: `For each ad campaign that you create, you can control how much - you're willing to spend on clicks and conversions, which networks - and geographical locations you want your ads to show on, and more.`, - }, - { - id: Steps.CREATION, - label: "Create Event", - description: - "An ad group contains one or more ads which target a shared set of keywords.", - }, - { - id: Steps.PAYMENT, - label: "Submit Payment", - description: `Try out different ad text to see what brings in the most customers, - and learn how to enhance your ads using features like ad extensions. - If you run into any problems with your ads, find out how to tell if - they're running and how to resolve approval issues.`, - }, - { - id: Steps.SUMMARY, - label: "Summary", - description: `TODO SHOW QR code and link: - Try out different ad text to see what brings in the most customers, - and learn how to enhance your ads using features like ad extensions. - If you run into any problems with your ads, find out how to tell if - they're running and how to resolve approval issues.`, - }, -]; - type State = { loading: boolean; activeStep: Steps; @@ -93,23 +55,26 @@ type Actions = { const initialState: State = { loading: false, - activeStep: Steps.AUTHORIZATION, + activeStep: Steps.UNKNOWN, eventId: undefined, dialogActions: { + [Steps.UNKNOWN]: [], [Steps.AUTHORIZATION]: [], - [Steps.CREATION]: [], + [Steps.REGISTRATION]: [], [Steps.PAYMENT]: [], [Steps.SUMMARY]: [], }, error: { + [Steps.UNKNOWN]: null, [Steps.AUTHORIZATION]: null, - [Steps.CREATION]: null, + [Steps.REGISTRATION]: null, [Steps.PAYMENT]: null, [Steps.SUMMARY]: null, }, complete: { + [Steps.UNKNOWN]: true, [Steps.AUTHORIZATION]: false, - [Steps.CREATION]: false, + [Steps.REGISTRATION]: false, [Steps.PAYMENT]: false, [Steps.SUMMARY]: false, }, @@ -140,6 +105,37 @@ const useStore = create()((set, get) => ({ }, })); +type StepInfo = { + id: Steps; + label: string; + description: React.ReactNode; +}; + +const stepInfo: StepInfo[] = [ + { + id: Steps.AUTHORIZATION, + label: "Authorize Minter", + description: "Set the platform wallet address as your authorized minter.", + }, + { + id: Steps.REGISTRATION, + label: "Register Event", + description: `Provide the event details. Information is stored on-chain + and cannot be modified once submitted.`, + }, + { + id: Steps.PAYMENT, + label: "Submit Payment", + description: `Transfer the event deposit to cover reserve requirements and transaction fees. + The deposit will be refunded once the event has ended.`, + }, + { + id: Steps.SUMMARY, + label: "Summary", + description: "Share the provided link to invite participants to the event.", + }, +]; + function CreateDialog() { const state = useStore(); const [open, setOpen] = React.useState(false); @@ -150,14 +146,8 @@ function CreateDialog() { state.setEventId(activeDialog.data?.eventId); }, [activeDialog]); - // TODO where and how do we reset the state (including step components) - // might be best, if each component does it themselves - // update active step React.useEffect(() => { - state.setActiveStep(Steps.SUMMARY); - return; - for (const step of stepInfo) { if (!state.complete[step.id]) { state.setActiveStep(step.id); @@ -170,16 +160,26 @@ function CreateDialog() { if (reason === "backdropClick") { return; } - // TODO reset dialog (including every step) - // state.reset(); - // setOpen(false); // doesnt fix strange change while close setActiveDialog({}); + state.reset(); }; const activeStepIndex = React.useMemo(() => { return stepInfo.findIndex((x) => x.id === state.activeStep); }, [state.activeStep]); + const createStepFields = (state: State & Actions, step: Steps) => ({ + active: state.activeStep === step, + loading: state.loading, + eventId: state.eventId, + setLoading: state.setLoading, + setEventId: state.setEventId, + setError: (text: string | null) => state.setError(step, text), + setComplete: (value: boolean) => state.setComplete(step, value), + setActions: (actions: React.ReactNode[]) => + state.setDialogActions(step, actions), + }); + return ( @@ -241,55 +241,13 @@ function CreateDialog() { state.setError(Steps.AUTHORIZATION, text)} - setComplete={(value) => - state.setComplete(Steps.AUTHORIZATION, value) - } - setActions={(actions) => - state.setDialogActions(Steps.AUTHORIZATION, actions) - } - /> - state.setError(Steps.CREATION, text)} - setComplete={(value) => state.setComplete(Steps.CREATION, value)} - setActions={(actions) => - state.setDialogActions(Steps.CREATION, actions) - } - /> - state.setError(Steps.PAYMENT, text)} - setComplete={(value) => state.setComplete(Steps.PAYMENT, value)} - setActions={(actions) => - state.setDialogActions(Steps.PAYMENT, actions) - } + {...createStepFields(state, Steps.AUTHORIZATION)} /> - state.setError(Steps.SUMMARY, text)} - setComplete={(value) => state.setComplete(Steps.SUMMARY, value)} - setActions={(actions) => - state.setDialogActions(Steps.SUMMARY, actions) - } + + + diff --git a/src/components/CreateDialog/PaymentStep.tsx b/src/components/CreateDialog/PaymentStep.tsx index 1906f02..a3b1998 100644 --- a/src/components/CreateDialog/PaymentStep.tsx +++ b/src/components/CreateDialog/PaymentStep.tsx @@ -3,7 +3,7 @@ import axios from "axios"; import { useSnackbar } from "notistack"; -import { Box, Button, Link } from "@mui/material"; +import { Box, Button, Grid, Link, Typography } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; @@ -13,6 +13,10 @@ import { useAuth } from "components/AuthContext"; import Loader from "components/Loader"; import { StepProps } from "./types"; import { Event } from "types"; +import InfoBox from "components/InfoBox"; +import ContentBox from "./ContentBox"; +import Debug from "components/Debug"; +import { dropsToXrp } from "xrpl"; function PaymentStep({ active, @@ -40,8 +44,6 @@ function PaymentStep({ } }, [active, data?.accounting]); - // TODO only load event info, if active, if not active reset - // fetch authorized minter info React.useEffect(() => { let mounted = true; @@ -51,35 +53,35 @@ function PaymentStep({ if (eventId && jwt) { const event = await API.event.getInfo(eventId, jwt); - // TODO - console.log(event); + if (!event?.accounting) { + throw Error("Unable to fetch event"); + } if (mounted) { + setError(null); setData(event); } } } catch (err) { + const msg = "Failed to load payment info"; console.debug(err); if (mounted) { + setError(msg); setData(undefined); } if (axios.isAxiosError(err)) { - enqueueSnackbar( - `Failed to load stats data: ${err.response?.data.error}`, - { - variant: "error", - } - ); + enqueueSnackbar(`${msg}: ${err.response?.data.error}`, { + variant: "error", + }); } else { - enqueueSnackbar("Failed to load stats data", { + enqueueSnackbar(`${msg}: ${(err as Error).message}`, { variant: "error", }); } } }; - // TODO only if active, but don't reset? - if (isAuthorized && active) { + if (active && isAuthorized) { load(); } else { setData(undefined); @@ -105,11 +107,9 @@ function PaymentStep({ `deposit event ${eventId}` ); const txHash = await result.resolved; - console.log(txHash); if (!txHash) { - // TODO - throw Error("Payment failed"); + throw Error("Transaction rejected"); } const success = await API.payment.check(jwt, { @@ -117,24 +117,23 @@ function PaymentStep({ txHash: txHash, }); - console.log(success); - - // TODO handle fail + if (!success) { + throw Error("Payment unconfirmed"); + } - // TODO force re-download event + // force update event info setCount((c) => c + 1); - // TODO reset local state? } - - // DEBUG } catch (err) { + const msg = "Failed to transfer payment"; console.debug(err); + setError(msg); if (axios.isAxiosError(err)) { - enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { + enqueueSnackbar(`${msg}: ${err.response?.data.error}`, { variant: "error", }); } else { - enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { + enqueueSnackbar(`${msg}: ${(err as Error).message}`, { variant: "error", }); } @@ -142,7 +141,7 @@ function PaymentStep({ setLoading(false); } }, - [eventId, jwt, networkId, data, provider, enqueueSnackbar, setLoading] + [eventId, jwt, networkId, data, provider] ); // set actions @@ -157,7 +156,7 @@ function PaymentStep({ loading || !isActive || !isAuthorized || !Boolean(data?.accounting) } > - Send + Pay , ]); } else { @@ -172,55 +171,141 @@ function PaymentStep({ handleConfirm, ]); - // TODO display "checking minter status" while we load the status with spinner + const reserveInfo = React.useMemo(() => { + if (data?.tokenCount) { + const tokenCount = data.tokenCount; + const baseReserve = 2; + const pageReserve = data.tokenCount / 16; + const pageReserveRounded = + baseReserve * Math.floor((data.tokenCount + 15) / 16); + const offerReserve = baseReserve * data.tokenCount; + return { + "Token Count": `${tokenCount}`, + "Base Reserve": `${baseReserve} XRP`, + "NFT Page Reserve": `${baseReserve} * ⌈(${tokenCount}/16)⌉ = ${baseReserve} * ⌈${pageReserve}⌉ = ${pageReserveRounded} XRP`, + "NFT Offer Reserve": `${baseReserve} * ${tokenCount} = ${offerReserve} XRP`, + "Total Reserve": `${pageReserveRounded} + ${offerReserve} = ${ + pageReserveRounded + offerReserve + } XRP`, + }; + } + }, [data?.tokenCount]); + + const depositInfo = React.useMemo(() => { + if (data?.accounting) { + const total = ( + BigInt(data.accounting.depositReserveValue) + + BigInt(data.accounting?.depositFeeValue) + ).toString(); + return { + "Deposit Address": data.accounting.depositAddress, + "Reserve Deposit": `${dropsToXrp( + data.accounting.depositReserveValue + )} XRP`, + "Tx Fee Deposit": `${dropsToXrp(data.accounting.depositFeeValue)} XRP`, + "Total Deposit": `${dropsToXrp(total)} XRP`, + "Transaction Fee": `~${dropsToXrp(15)} XRP`, + }; + } + }, [data?.accounting]); + return active ? ( -

    isAuthorized: {isAuthorized ? "true" : "false"}

    + + + Ledger objects are owned by a specific account. Each object + contributes to the owner's reserve requirement. More details can be + found in the XRPL{" "} + + documentation + + . + + - {data?.accounting ? ( + {data && reserveInfo && depositInfo ? ( -

    Reserve Deposit Breakdown

    -

    For tokenCount: {data.tokenCount}

    - - Base Owner Reserve = 2 XRP NFTokenPage Reserve = 2 * - ([tokenCount]/16) = 8.33 = 10 XRP (Reserves round up) NFTokenOffer - Reserve = 2 * [tokenCount] = 200 XRP Total Deposit = TODO - - - Details here - - - Details here - -
    + Reserve Breakdown: + + + + {Object.entries(reserveInfo).map(([key, value], index) => ( + + + + {key}: {value} + + + + ))} + + - -

    txHash: {data.accounting.depositTxHash}

    -

    Deposit Address: {data.accounting.depositAddress}

    -

    Deposit Reserve: {data.accounting.depositReserveValue}

    -

    Deposit Fee: {data.accounting.depositFeeValue}

    -

    - Deposit Total:{" "} - {( - BigInt(data.accounting.depositReserveValue) + - BigInt(data.accounting?.depositFeeValue) - ).toString()} -

    -

    Transaction Cost: ~15 drops

    + + Deposit Info: + + + + {Object.entries(depositInfo).map(([key, value], index) => ( + + + + {key} + + + + + {value} + + + + ))} + +
    ) : ( - + )} + +
    ) : null; } diff --git a/src/components/CreateDialog/CreationStep.tsx b/src/components/CreateDialog/RegistrationStep.tsx similarity index 91% rename from src/components/CreateDialog/CreationStep.tsx rename to src/components/CreateDialog/RegistrationStep.tsx index b7c9965..f063f63 100644 --- a/src/components/CreateDialog/CreationStep.tsx +++ b/src/components/CreateDialog/RegistrationStep.tsx @@ -1,7 +1,5 @@ import React from "react"; import axios from "axios"; -import { useAtom } from "jotai"; -import type { ReactNode } from "react"; import { useSnackbar } from "notistack"; import { useForm, @@ -20,14 +18,14 @@ import { } from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Box, Button, Stack, TextField } from "@mui/material"; - +import { Box, Button, Stack, TextField, Typography } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import API from "apis"; import { useWeb3 } from "connectors/context"; import { useAuth } from "components/AuthContext"; +import InfoBox from "components/InfoBox"; import { StepProps } from "./types"; const schemaCommon = object({ @@ -102,7 +100,7 @@ const defaultValues: DefaultValues = { isPublic: true, }; -function CreationStep({ +function RegistrationStep({ active, loading, eventId, @@ -114,7 +112,6 @@ function CreationStep({ }: StepProps) { const { networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); - // const [loading, setLoading] = React.useState(false); const { enqueueSnackbar } = useSnackbar(); const { @@ -137,6 +134,8 @@ function CreationStep({ React.useEffect(() => { if (active) { setComplete(Boolean(eventId)); + } else { + reset(); } }, [active, eventId]); @@ -156,40 +155,32 @@ function CreationStep({ dateEnd: values.dateEnd!, isManaged: !values.isPublic, }); - console.debug("CreateResult", result); - enqueueSnackbar(`Creation successful: Event #${result.eventId}`, { + + enqueueSnackbar(`Registration successful: Event #${result.eventId}`, { variant: "success", }); setEventId(result.eventId); - // TODO - // reset(); + reset(); } } catch (err) { + const msg = "Failed to register event"; console.debug(err); + setError(msg); if (axios.isAxiosError(err)) { - enqueueSnackbar(`Creation failed: ${err.response?.data.error}`, { + enqueueSnackbar(`${msg}: ${err.response?.data.error}`, { variant: "error", }); } else { - enqueueSnackbar(`Creation failed: ${(err as Error).message}`, { + enqueueSnackbar(`${msg}: ${(err as Error).message}`, { variant: "error", }); } } finally { setLoading(false); - // setActiveDialog({}); } }, - [ - enqueueSnackbar, - isAuthorized, - jwt, - networkId, - // reset, - setLoading, - setEventId, - ] + [isAuthorized, jwt, networkId, reset] ); // set actions @@ -202,7 +193,7 @@ function CreationStep({ startIcon={loading && } disabled={loading || !isAuthorized || !isValid} > - Create + Register , ]); } else { @@ -212,6 +203,12 @@ function CreationStep({ return active ? ( + + + Each event slot has a reserve requirement of ~2.1 XRP + , which has to be deposited for the duration of the event! + + ) => { - // TODO reset local state - // setData(undefined); - }, - [] - ); - const handleDownload = React.useCallback(() => { const canvas = document.getElementById("qrcode") as HTMLCanvasElement; if (canvas) { @@ -108,8 +97,7 @@ function SummaryStep({ , @@ -117,16 +105,14 @@ function SummaryStep({ } else { setActions([]); } - }, [active, loading, data, handleConfirm, handleDownload]); + }, [active, data, handleDownload]); return active ? ( - + + + The QR code and link can also be viewed in the dashboard. + + {data ? ( - theme.palette.grey[100], - border: "1px solid", - borderRadius: "4px", - borderColor: (theme) => theme.palette.grey[300], padding: "0.75rem", - marginTop: "1rem", position: "relative", }} > @@ -192,14 +173,10 @@ function SummaryStep({ - - + ) : ( - + )} ) : null; diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 07fb444..9c43509 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -64,3 +64,5 @@ export function Debug(props: DebugProps) { ); } + +export default Debug; diff --git a/src/components/InfoBox.tsx b/src/components/InfoBox.tsx index 884cc03..fb8a59d 100644 --- a/src/components/InfoBox.tsx +++ b/src/components/InfoBox.tsx @@ -9,16 +9,17 @@ import { } from "@mui/material"; type InfoBoxProps = { + title?: string; sx?: SxProps; - text: React.ReactNode; + children?: React.ReactNode; }; -function InfoBox({ sx, text }: InfoBoxProps) { +function InfoBox({ title, sx, children }: InfoBoxProps) { return ( - Info - {text} + {title ?? "Info"} + {children} ); From c77fa83455ab7bf0f5c0b1a757d6a1731c13d88d Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 18 Sep 2023 13:15:16 +0200 Subject: [PATCH 078/135] rework link dialog to re-use step --- src/components/LinkDialog.tsx | 158 ++++------------------------------ 1 file changed, 19 insertions(+), 139 deletions(-) diff --git a/src/components/LinkDialog.tsx b/src/components/LinkDialog.tsx index 4a0ae2b..dabba84 100644 --- a/src/components/LinkDialog.tsx +++ b/src/components/LinkDialog.tsx @@ -1,119 +1,43 @@ import React from "react"; -import axios from "axios"; import { useAtom } from "jotai"; -import { useSnackbar } from "notistack"; -import { QRCodeCanvas } from "qrcode.react"; -import { saveAs } from "file-saver"; import { Button, - Box, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, - Tooltip, } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -import ContentCopyIcon from "@mui/icons-material/ContentCopy"; -import API from "apis"; import { activeDialogAtom } from "states/atoms"; import { DialogIdentifier } from "types"; -import { useAuth } from "components/AuthContext"; -import Loader from "components/Loader"; +import SummaryStep from "./CreateDialog/SummaryStep"; type LinkDialogData = Record; function LinkDialog() { - const { isAuthenticated, jwt, permissions } = useAuth(); const [open, setOpen] = React.useState(false); const [data, setData] = React.useState(); - const [link, setLink] = React.useState(); + const [actions, setActions] = React.useState([]); const [activeDialog, setActiveDialog] = useAtom(activeDialogAtom); - const { enqueueSnackbar } = useSnackbar(); - - const isAuthorized = React.useMemo(() => { - return isAuthenticated && permissions.includes("organizer"); - }, [isAuthenticated, permissions]); React.useEffect(() => { setOpen(activeDialog.type === DialogIdentifier.DIALOG_LINK); setData(activeDialog.data); }, [activeDialog]); - React.useEffect(() => { - let mounted = true; - - const load = async () => { - try { - if (jwt && data?.eventId) { - const masked = await API.event.getLink(jwt, data.eventId); - if (mounted) { - setLink(`${window.location.origin}/claim/${masked}`); - } - } - } catch (err) { - console.debug(err); - if (mounted) { - setLink(undefined); - } - if (axios.isAxiosError(err)) { - enqueueSnackbar( - `Failed to load event link: ${err.response?.data.error}`, - { - variant: "error", - } - ); - } else { - enqueueSnackbar("Failed to load event link", { - variant: "error", - }); - } - } - }; - - if (open && data) { - if (isAuthorized) { - load(); - } else { - setLink(undefined); - } - } - - return () => { - mounted = false; - }; - }, [open, data, jwt, isAuthorized]); - const handleClose = (event: {}, reason?: string) => { if (reason === "backdropClick") { return; } setData(undefined); + setActions([]); setActiveDialog({}); }; - const handleConfirm = async (event: React.MouseEvent) => { - setData(undefined); - setActiveDialog({}); - }; - - const handleCopy = async (text: string) => { - await navigator.clipboard.writeText(text); - }; - - const handleDownload = () => { - const canvas = document.getElementById("qrcode") as HTMLCanvasElement; - if (canvas) { - canvas.toBlob((blob) => { - saveAs(blob!, "qr.png"); - }); - } - }; - return ( Invitation link for Event #{data?.eventId} + + - {link ? ( - - - - - theme.palette.grey[100], - border: "1px solid", - borderRadius: "4px", - borderColor: (theme) => theme.palette.grey[300], - padding: "0.75rem", - marginTop: "1rem", - position: "relative", - }} - > - - {link} - - - handleCopy(link)}> - - - - - - ) : ( - - )} + {}} + setEventId={() => {}} + setError={() => {}} + setComplete={() => {}} + setActions={setActions} + /> - - + {actions.map((button, index) => ( + {button} + ))} ); From 5532213787ae9976ca91d378a5bd394cad66367f Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 18 Sep 2023 13:17:08 +0200 Subject: [PATCH 079/135] update event card --- src/components/EventCard.tsx | 164 ++++++++++++++++++++++++++++++++--- src/layouts/MainLayout.tsx | 11 ++- 2 files changed, 162 insertions(+), 13 deletions(-) diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 84cf5ee..836e278 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -1,34 +1,139 @@ import * as React from "react"; import { Link } from "react-router-dom"; +import { useSetAtom } from "jotai"; import { + Box, + Button, Card, + CardActionArea, CardActions, CardContent, CardMedia, - CardActionArea, - Button, - Typography, IconButton, + Tooltip, + Typography, } from "@mui/material"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; -import { Event } from "types"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import BlockIcon from "@mui/icons-material/Block"; + +import { Event, DialogIdentifier, EventStatus } from "types"; +import { activeDialogAtom } from "states/atoms"; type EventCardProps = { event: Event; }; function EventCard({ event }: EventCardProps) { + const setActiveDialog = useSetAtom(activeDialogAtom); + + const handleShare = React.useCallback( + (id: number) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_LINK, + data: { eventId: id }, + }); + }, + [setActiveDialog] + ); + + const handleCancel = React.useCallback( + (id: number) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_CANCEL, + data: { eventId: id }, + }); + }, + [setActiveDialog] + ); + + const handlePay = React.useCallback( + (id: number) => { + setActiveDialog({ + type: DialogIdentifier.DIALOG_CREATE, + data: { eventId: id }, + }); + }, + [setActiveDialog] + ); + + const statusIcon = React.useMemo(() => { + switch (event.status) { + case EventStatus.PENDING: + return ( + + + + + + ); + case EventStatus.PAID: + return ( + + + + + + ); + case EventStatus.ACTIVE: + return ( + + + + + + ); + case EventStatus.CANCELED: + case EventStatus.CLOSED: + return ( + + + + + + ); + case EventStatus.REFUNDED: + return ( + + + + + + ); + } + }, [event.status]); + + const options: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "long", + day: "numeric", + }; + return ( - + {event.title} @@ -36,20 +141,57 @@ function EventCard({ event }: EventCardProps) { Event #{event.id} - {event.description} + Date Start:{" "} + {new Date(event.dateStart).toLocaleDateString(undefined, options)} + + + Date End:{" "} + {new Date(event.dateEnd).toLocaleDateString(undefined, options)} + + + Taken Slots: {event.attendees?.length}/ + {event.tokenCount} - + )} + - - - - ); diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 564df52..9b136be 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -12,7 +12,7 @@ import CancelDialog from "components/CancelDialog"; import ClaimDialog from "components/ClaimDialog"; import CreateDialog from "components/CreateDialog"; import JoinDialog from "components/JoinDialog"; -import LinkDialog from "components/LinkDialog" +import LinkDialog from "components/LinkDialog"; import ProfileDialog from "components/ProfileDialog"; function MainLayout(props: any) { @@ -21,7 +21,14 @@ function MainLayout(props: any) { return (
    - + Date: Mon, 18 Sep 2023 13:17:28 +0200 Subject: [PATCH 080/135] disable debug mode --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 7fae0e9..028a3aa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,7 +17,7 @@ type Config = { }; const DEFAULT: Config = { - debug: true, + debug: false, apiURL: process.env.REACT_APP_URL_POAP_API as string, timeout: 5000, storage: window.sessionStorage, From 9555b9857d0ff7d99d99e99f7cfc652a409391b8 Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 19 Sep 2023 08:10:02 +0200 Subject: [PATCH 081/135] [wip] rework claim page --- src/components/ClaimSetup/ClaimSetup.tsx | 213 +++++++++++ src/components/ClaimSetup/ClaimStep.tsx | 204 ++++++++++ src/components/ClaimSetup/ConnectStep.tsx | 104 +++++ src/components/ClaimSetup/SummaryStep.tsx | 34 ++ src/components/ClaimSetup/index.ts | 1 + src/components/ClaimSetup/types.ts | 11 + src/pages/ClaimPage.tsx | 444 +--------------------- 7 files changed, 569 insertions(+), 442 deletions(-) create mode 100644 src/components/ClaimSetup/ClaimSetup.tsx create mode 100644 src/components/ClaimSetup/ClaimStep.tsx create mode 100644 src/components/ClaimSetup/ConnectStep.tsx create mode 100644 src/components/ClaimSetup/SummaryStep.tsx create mode 100644 src/components/ClaimSetup/index.ts create mode 100644 src/components/ClaimSetup/types.ts diff --git a/src/components/ClaimSetup/ClaimSetup.tsx b/src/components/ClaimSetup/ClaimSetup.tsx new file mode 100644 index 0000000..1d8534a --- /dev/null +++ b/src/components/ClaimSetup/ClaimSetup.tsx @@ -0,0 +1,213 @@ +import React from "react"; +import { useAtom } from "jotai"; +import { create } from "zustand"; +import { isMobile } from "react-device-detect"; + +import { Alert, Box, Collapse, Step, StepLabel, Stepper } from "@mui/material"; + +import { useAuth } from "components/AuthContext"; +import { selectedWalletAtom } from "states/atoms"; +import { xumm } from "connectors/xumm"; +import { getConnector } from "connectors"; +import type { Connector } from "connectors/connector"; +import { ConnectorType } from "types"; + +import ClaimStep from "./ClaimStep"; +import SummaryStep from "./SummaryStep"; +import ConnectStep from "./ConnectStep"; + +enum Steps { + UNKNOWN, + CONNECT, + CLAIM, + SUMMARY, +} + +type State = { + loading: boolean; + activeStep: Steps; + maskedEventId?: string; + error: { [key in Steps]: string | null }; + complete: { [key in Steps]: boolean }; +}; + +type Actions = { + setLoading: (value: boolean) => void; + setActiveStep: (step: Steps) => void; + setMaskedEventId: (value?: string) => void; + setError: (step: Steps, text: string | null) => void; + setComplete: (step: Steps, value: boolean) => void; + reset: () => void; +}; + +const initialState: State = { + loading: false, + activeStep: Steps.UNKNOWN, + maskedEventId: undefined, + error: { + [Steps.UNKNOWN]: null, + [Steps.CONNECT]: null, + [Steps.CLAIM]: null, + [Steps.SUMMARY]: null, + }, + complete: { + [Steps.UNKNOWN]: true, + [Steps.CONNECT]: false, + [Steps.CLAIM]: false, + [Steps.SUMMARY]: false, + }, +}; + +const useStore = create()((set, get) => ({ + ...initialState, + setComplete: (step: Steps, value: boolean) => { + set({ complete: { ...get().complete, [step]: value } }); + }, + setError: (step: Steps, text: string | null) => { + set({ error: { ...get().error, [step]: text } }); + }, + setLoading: (value: boolean) => { + set({ loading: value }); + }, + setMaskedEventId: (value?: string) => { + set({ maskedEventId: value }); + }, + setActiveStep: (step: Steps) => { + set({ activeStep: step }); + }, + reset: () => { + set(initialState); + }, +})); + +type StepInfo = { + id: Steps; + label: string; +}; + +const stepInfo: StepInfo[] = [ + { + id: Steps.CONNECT, + label: "Connect Wallet", + }, + { + id: Steps.CLAIM, + label: "Claim NFT", + }, + { + id: Steps.SUMMARY, + label: "Finished", + }, +]; + +function ClaimSetup() { + const state = useStore(); + const { isAuto, toggleAuto, setClaimFlow } = useAuth(); + const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); + + // force enable auto login + React.useEffect(() => { + if (!isAuto) { + toggleAuto(); + } + }, [isAuto, toggleAuto]); + + // eagerly connect (refresh) + React.useEffect(() => { + let selectedConnector: Connector | undefined; + if (selectedWallet) { + try { + selectedConnector = getConnector(selectedWallet as ConnectorType); + } catch { + setSelectedWallet(ConnectorType.EMPTY); + } + } + + if (selectedConnector && !selectedConnector.state.isActive()) { + if (selectedConnector.getType() === ConnectorType.XUMM) { + selectedConnector.activate(); + } + } + }, [selectedWallet, setSelectedWallet]); + + // eagerly connect (deeplink redirect) + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + await xumm.activate(); + if (mounted) { + state.setError(Steps.CONNECT, null); + setSelectedWallet(ConnectorType.XUMM); + } + } catch (err) { + console.log(err); + if (mounted) { + state.setError( + Steps.CONNECT, + `Failed to connect wallet (redirect): ${(err as Error).message}` + ); + } + } + }; + + const params = new URLSearchParams(document?.location?.search || ""); + if (params.get("access_token")) { + load(); + } + + return () => { + mounted = false; + }; + }, []); + + // update active step + React.useEffect(() => { + for (const step of stepInfo) { + if (!state.complete[step.id]) { + state.setActiveStep(step.id); + break; + } + } + }, [state.complete]); + + const createStepFields = (state: State & Actions, step: Steps) => ({ + active: state.activeStep === step, + loading: state.loading, + setLoading: state.setLoading, + setError: (text: string | null) => state.setError(step, text), + setComplete: (value: boolean) => state.setComplete(step, value), + }); + + const activeStepIndex = React.useMemo(() => { + return stepInfo.findIndex((x) => x.id === state.activeStep); + }, [state.activeStep]); + + return ( + + + {state.error[state.activeStep]} + + + + + + + + {stepInfo.map((step, index) => ( + + {step.label} + + ))} + + + + ); +} + +export default ClaimSetup; diff --git a/src/components/ClaimSetup/ClaimStep.tsx b/src/components/ClaimSetup/ClaimStep.tsx new file mode 100644 index 0000000..5d0c0ec --- /dev/null +++ b/src/components/ClaimSetup/ClaimStep.tsx @@ -0,0 +1,204 @@ +import React from "react"; +import { useParams } from "react-router-dom"; +import axios from "axios"; +import { isMobile } from "react-device-detect"; + +import { Box, Button } from "@mui/material"; +import CircularProgress from "@mui/material/CircularProgress"; + +import API from "apis"; +import { useAuth } from "components/AuthContext"; +import Debug from "components/Debug"; +import { useWeb3 } from "connectors/context"; +import { Claim } from "types"; +import { StepProps } from "./types"; + +function SummaryStep({ + active, + loading, + setError, + setComplete, + setLoading, +}: StepProps) { + const { isActive, account, provider, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + const [uuid, setUuid] = React.useState(); + const [count, setCount] = React.useState(0); + + const { id } = useParams(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("attendee"); + }, [isAuthenticated, permissions]); + + // update parent state + React.useEffect(() => { + if (active) { + setComplete(Boolean(data?.claimed)); + } + }, [active, data]); + + // fetch claim offer + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (jwt && id) { + const offer = await API.event.claim(jwt, { + maskedEventId: id, + }); + + if (mounted) { + setError(null); + setData(offer); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + if (axios.isAxiosError(err)) { + setError(`Failed to load offer data: ${err.response?.data.error}`); + } else { + setError(`Failed to load offer data: ${(err as Error).message}`); + } + setData(undefined); + } + } + }; + + if (active && isAuthorized) { + load(); + } else { + setData(undefined); + } + + return () => { + mounted = false; + }; + }, [active, isAuthorized, jwt, id, count]); + + // TODO make useCallback + // TODO change account -> isActive ? + const handleClaim = React.useCallback( + async (event: React.MouseEvent) => { + setLoading(true); + try { + if (provider && account && id && jwt) { + let offer = data; + + // join event + if (offer === null) { + offer = await API.event.join(jwt, { + maskedEventId: id, + createOffer: true, + }); + console.debug("JoinResult", offer); + // enqueueSnackbar(`Sign-up successful: Event #${id}`, { + // variant: "success", + // }); + setData(offer); + } + + // claim nft + if (offer?.offerIndex && !offer?.claimed) { + // TODO this might actually be helpful + // enqueueSnackbar( + // "Creating NFT claim request (confirm the transaction in your wallet)", + // { + // variant: "warning", + // autoHideDuration: 30000, + // } + // ); + const result = await provider.acceptOffer(offer.offerIndex); + + setUuid(result.uuid); + + const txHash = await result.resolved; + console.log("txHash", txHash); + + if (txHash) { + // enqueueSnackbar("Claim successful", { + // variant: "success", + // }); + // TODO trigger accepted to reload claim info + + // force update claim info + setCount((c) => c + 1); + + // bad + // offer.claimed = true; + // setData(offer); + } else { + setError(`Claim failed: Unable to claim NFT`); + setUuid(undefined); + } + } + } + } catch (err) { + console.debug(err); + if (axios.isAxiosError(err)) { + setError(`Sign-up failed: ${err.response?.data.error}`); + } else { + setError(`Sign-up failed: ${(err as Error).message}`); + } + } finally { + console.log("Finally being called"); + setLoading(false); + // setData(undefined); + // setActiveDialog({}); + } + }, + [provider, account, id, jwt, data] + ); + + return active ? ( + + + + {isMobile && uuid && ( + + + + )} + + + + ) : null; +} + +export default SummaryStep; diff --git a/src/components/ClaimSetup/ConnectStep.tsx b/src/components/ClaimSetup/ConnectStep.tsx new file mode 100644 index 0000000..cd3ec01 --- /dev/null +++ b/src/components/ClaimSetup/ConnectStep.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { useAtom } from "jotai"; + +import { Box, Button } from "@mui/material"; +import CircularProgress from "@mui/material/CircularProgress"; + +import { useAuth } from "components/AuthContext"; +import { Debug } from "components/Debug"; +import { useWeb3 } from "connectors/context"; +import { selectedWalletAtom } from "states/atoms"; +import { xumm } from "connectors/xumm"; +import { ConnectorType } from "types"; +import { StepProps } from "./types"; + +// TODO need a way to change/disconnect a wallet +// TODO need to check isAuthenticated somewhere and display error if needed + +function ConnectStep({ + active, + loading, + setError, + setComplete, + setLoading, +}: StepProps) { + const { connector, isActive } = useWeb3(); + const { isAuthenticated, permissions, setClaimFlow } = useAuth(); + const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("attendee"); + }, [isAuthenticated, permissions]); + + // update parent state + React.useEffect(() => { + if (active) { + setComplete(isActive && isAuthorized); + } + }, [active, isActive, isAuthorized]); + + const handleConnect = React.useCallback(async () => { + // disconnect, if not Xumm + if (isActive && connector?.getType() !== ConnectorType.XUMM) { + try { + if (connector?.deactivate) { + await connector.deactivate(); + } else { + await connector?.reset(); + } + setSelectedWallet(ConnectorType.EMPTY); + setError(null); + } catch (err) { + console.debug(err); + setError(`Failed to disconnect wallet: ${(err as Error).message}`); + return; + } + } + + // set login flow + setClaimFlow(true); + + setLoading(true); + try { + await xumm.activate(); + setSelectedWallet(ConnectorType.XUMM); + setError(null); + } catch (err) { + console.debug(err); + setError(`Failed to connect wallet: ${(err as Error).message}`); + } finally { + setLoading(false); + } + }, [connector, isActive, setSelectedWallet, setClaimFlow]); + + return active ? ( + + + + + + ) : null; +} + +export default ConnectStep; diff --git a/src/components/ClaimSetup/SummaryStep.tsx b/src/components/ClaimSetup/SummaryStep.tsx new file mode 100644 index 0000000..944804e --- /dev/null +++ b/src/components/ClaimSetup/SummaryStep.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +import { Box } from "@mui/material"; + +import { StepProps } from "./types"; + +function SummaryStep({ + active, + loading, + setError, + setComplete, + setLoading, +}: StepProps) { + // update parent state + React.useEffect(() => { + if (active) { + setComplete(true); + } + }, [active]); + + return active ? ( + + Successfully claimed NFT! + + ) : null; +} + +export default SummaryStep; diff --git a/src/components/ClaimSetup/index.ts b/src/components/ClaimSetup/index.ts new file mode 100644 index 0000000..d6dd29f --- /dev/null +++ b/src/components/ClaimSetup/index.ts @@ -0,0 +1 @@ +export { default } from "./ClaimSetup"; diff --git a/src/components/ClaimSetup/types.ts b/src/components/ClaimSetup/types.ts new file mode 100644 index 0000000..70cab6c --- /dev/null +++ b/src/components/ClaimSetup/types.ts @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +// Note: only use loading to enable/disable buttons +export type StepProps = { + active: boolean; + loading: boolean; + // isMobile: boolean; + setLoading: (value: boolean) => void; + setError: (text: string | null) => void; + setComplete: (value: boolean) => void; +}; diff --git a/src/pages/ClaimPage.tsx b/src/pages/ClaimPage.tsx index 0e3289e..c47a81d 100644 --- a/src/pages/ClaimPage.tsx +++ b/src/pages/ClaimPage.tsx @@ -1,456 +1,16 @@ import React from "react"; -import { useAtom } from "jotai"; -import { useParams, useNavigate } from "react-router-dom"; -import axios from "axios"; -import { useAtomValue } from "jotai"; -import { useSnackbar } from "notistack"; -import { isMobile } from "react-device-detect"; -import { - Alert, - Box, - Button, - Collapse, - MobileStepper, - Step, - StepLabel, - Stepper, - Typography, -} from "@mui/material"; -import CircularProgress from "@mui/material/CircularProgress"; - -import { Claim } from "types"; -import { useAuth } from "components/AuthContext"; -import { useWeb3 } from "connectors/context"; -import API from "apis"; import ContentWrapper from "components/ContentWrapper"; - -import { selectedWalletAtom } from "states/atoms"; -import { shortenAddress } from "utils/strings"; -import { xumm } from "connectors/xumm"; -import { getConnector } from "connectors"; -import type { Connector } from "connectors/connector"; -import { ConnectorType } from "types"; -import { Debug } from "components/Debug"; - -// TODO https://m1.material.io/components/steppers.html#steppers-types-of-steps - -// TODO remove enqueueSnackbar, use stepper to display errors/problems - -type StepState = { - name: string; - label: string; - completed: boolean; - error?: string; -}; - -type State = { - activeStep: string; - errors: string[]; - completed: boolean[]; -}; - -const steps = ["Connect Wallet", "Claim NFT", "Finished"]; - -type stepIds = "connect" | "claim" | "finish"; - -type IStep = { - id: stepIds; - label: string; - node?: React.ReactNode; -}; - -const steps2: IStep[] = [ - { - id: "connect", - label: "Connect Wallet", - }, - { id: "claim", label: "Claim NFT" }, - { id: "finish", label: "Finished" }, -]; - -type IView = {}; - -// errors[id] = string | undefined +import ClaimSetup from "components/ClaimSetup"; function ClaimPage() { - const { connector, provider, account, isActive } = useWeb3(); - const { isAuthenticated, jwt, permissions, isAuto, toggleAuto, setClaimFlow } = useAuth(); - const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); - const [data, setData] = React.useState(); - const [uuid, setUuid] = React.useState(); - const [errors, setErrors] = React.useState(steps.map(() => "")); - const [completed, setCompleted] = React.useState(); - const [count, setCount] = React.useState(0); - const [loading, setLoading] = React.useState(false); - const [activeStep, setActiveStep] = React.useState(0); - const { enqueueSnackbar } = useSnackbar(); - - const { id } = useParams(); - - // force enable auto login - React.useEffect(() => { - if (!isAuto) { - toggleAuto(); - } - }, [isAuto, toggleAuto]); - - // eagerly connect (refresh) - React.useEffect(() => { - let selectedConnector: Connector | undefined; - if (selectedWallet) { - try { - selectedConnector = getConnector(selectedWallet as ConnectorType); - } catch { - setSelectedWallet(ConnectorType.EMPTY); - } - } - - if (selectedConnector && !selectedConnector.state.isActive()) { - if (selectedConnector.getType() === ConnectorType.XUMM) { - selectedConnector.activate(); - } - } - }, [selectedWallet, setSelectedWallet]); - - // eagerly connect (deeplink redirect) - React.useEffect(() => { - let mounted = true; - - const load = async () => { - try { - await xumm.activate(); - if (mounted) { - setSelectedWallet(ConnectorType.XUMM); - } - } catch (err) { - // TODO step 0 error - // if (mounted) { - // setErrors() - // } - enqueueSnackbar( - `Failed to connect wallet (redirect): ${(err as Error).message}`, - { - variant: "error", - } - ); - } - }; - - const params = new URLSearchParams(document?.location?.search || ""); - if (params.get("access_token")) { - load(); - } - - return () => { - mounted = false; - }; - }, []); - - // fetch claim offer - React.useEffect(() => { - let mounted = true; - - const load = async () => { - try { - if (jwt && id) { - const offer = await API.event.claim(jwt, { - maskedEventId: id, - }); - - if (mounted) { - setData(offer); - } - } - } catch (err) { - console.debug(err); - if (mounted) { - setData(undefined); - } - // TODO step 1: erorr - if (axios.isAxiosError(err)) { - enqueueSnackbar( - `Failed to load offer data: ${err.response?.data.error}`, - { - variant: "error", - } - ); - } else { - enqueueSnackbar("Failed to load offer data", { - variant: "error", - }); - } - } - }; - - if (isAuthenticated) { - load(); - } else { - setData(undefined); - } - - return () => { - mounted = false; - }; - }, [isAuthenticated, jwt, id, count]); - - // TODO update step - // TODO update completed, error ? - React.useEffect(() => { - // setCompleted(step0: isActive) - - // TODO need to check isAuthenticated somewhere and display error if needed - - if (!isActive || !isAuthenticated) { - // not connected - setActiveStep(0); - } else if (!data?.claimed) { - // nft not claimed - setActiveStep(1); - } else { - setActiveStep(2); - } - }, [isActive, isAuthenticated, data]); - - // const findCurrentStep = React.useCallback(async () => { - // // connected ? - // if (!isActive) { - // return 1; - // } - - // // claimed ? - // if (!data?.claimed) { - // return 2; - // } - - // return 3; - // }, [isActive]); - - const handleConnect = React.useCallback(async () => { - // disconnect, if not Xumm - if (isActive && connector?.getType() !== ConnectorType.XUMM) { - try { - if (connector?.deactivate) { - await connector.deactivate(); - } else { - await connector?.reset(); - } - setSelectedWallet(ConnectorType.EMPTY); - } catch (err) { - enqueueSnackbar( - `Failed to disconnect wallet: ${(err as Error).message}`, - { variant: "error" } - ); - return; - } - } - - // set login flow - setClaimFlow(true); - - setLoading(true); - try { - await xumm.activate(); - setSelectedWallet(ConnectorType.XUMM); - } catch (err) { - enqueueSnackbar(`Failed to connect wallet: ${(err as Error).message}`, { - variant: "error", - }); - } finally { - setLoading(false); - } - }, [connector, isActive, setSelectedWallet, setClaimFlow, enqueueSnackbar]); - - // TODO make useCallback - const handleClaim = async (event: React.MouseEvent) => { - setLoading(true); - try { - if (provider && account && id && jwt) { - let offer = data; - - // join event - if (offer === null) { - offer = await API.event.join(jwt, { - maskedEventId: id, - createOffer: true, - }); - console.debug("JoinResult", offer); - enqueueSnackbar(`Sign-up successful: Event #${id}`, { - variant: "success", - }); - setData(offer); - } - - // claim nft - if (offer?.offerIndex && !offer?.claimed) { - enqueueSnackbar( - "Creating NFT claim request (confirm the transaction in your wallet)", - { - variant: "warning", - autoHideDuration: 30000, - } - ); - const result = await provider.acceptOffer(offer.offerIndex); - - setUuid(result.uuid); - - const txHash = await result.resolved; - console.log("txHash", txHash); - - if (txHash) { - enqueueSnackbar("Claim successful", { - variant: "success", - }); - // TODO trigger accepted to reload claim info - - // force reload claim info - setCount(count + 1); // TODO use reducer? - // bad - // offer.claimed = true; - // setData(offer); - } else { - enqueueSnackbar(`Claim failed: Unable to claim NFT`, { - variant: "error", - }); - setUuid(undefined); - } - } - } - } catch (err) { - console.debug(err); - if (axios.isAxiosError(err)) { - enqueueSnackbar(`Sign-up failed: ${err.response?.data.error}`, { - variant: "error", - }); - } else { - enqueueSnackbar(`Sign-up failed: ${(err as Error).message}`, { - variant: "error", - }); - } - } finally { - console.log("Finally being called"); - setLoading(false); - // setData(undefined); - // setActiveDialog({}); - } - }; - return ( - - errors[activeStep] - - - {(() => { - switch (activeStep) { - case 0: - return ( - - - - ); - case 1: - return ( - - - - {isMobile && uuid && ( - - - - )} - - ); - case 2: - return ( - - Successfully claimed NFT! - - ); - default: - return null; - } - })()} - - - - - - {steps.map((label) => ( - - {label} - - ))} - - + ); } From acd1a840cb1cb49847228a3398c181df56809718 Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 19 Sep 2023 12:25:49 +0200 Subject: [PATCH 082/135] update layouts --- src/layouts/BasicLayout.tsx | 20 ++++++++++---------- src/layouts/MainLayout.tsx | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/layouts/BasicLayout.tsx b/src/layouts/BasicLayout.tsx index 43675e2..83170b8 100644 --- a/src/layouts/BasicLayout.tsx +++ b/src/layouts/BasicLayout.tsx @@ -3,18 +3,18 @@ import { Outlet } from "react-router-dom"; import Box from "@mui/material/Box"; import Container from "@mui/material/Container"; -function BasicLayout(props: any) { +function BasicLayout() { return ( - - + + diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 9b136be..4dc972b 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -15,7 +15,7 @@ import JoinDialog from "components/JoinDialog"; import LinkDialog from "components/LinkDialog"; import ProfileDialog from "components/ProfileDialog"; -function MainLayout(props: any) { +function MainLayout() { const activeDialog = useAtomValue(activeDialogAtom); return ( From f8928d9b4da2e4c5ae94728556d61889b9a63275 Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 19 Sep 2023 12:26:55 +0200 Subject: [PATCH 083/135] add return_url if on mobile --- src/connectors/gem.ts | 11 +++-- src/connectors/provider.ts | 15 +++++-- src/connectors/xumm.ts | 85 +++++++++++++++++++++++--------------- 3 files changed, 70 insertions(+), 41 deletions(-) diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index fe18882..c50fc21 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -30,7 +30,10 @@ export class GemWalletProvider extends Provider { this.authData = authData; } - public async acceptOffer(id: string): Promise { + public async acceptOffer( + id: string, + isMobile?: boolean + ): Promise { const response = await Gem.acceptNFTOffer({ NFTokenSellOffer: id, }); @@ -44,7 +47,8 @@ export class GemWalletProvider extends Provider { } public async setAccount( - minterAddress: string + minterAddress: string, + isMobile?: boolean ): Promise { const response = await Gem.setAccount({ NFTokenMinter: minterAddress, @@ -62,7 +66,8 @@ export class GemWalletProvider extends Provider { public async sendPayment( value: string, destination: string, - memo?: string + memo?: string, + isMobile?: boolean ): Promise { const response = await Gem.sendPayment({ amount: value, diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts index fb86327..0add835 100644 --- a/src/connectors/provider.ts +++ b/src/connectors/provider.ts @@ -5,15 +5,22 @@ export type AuthData = { export type ProviderRequestResult = { resolved: Promise; uuid?: string; -} +}; export abstract class Provider { - public abstract acceptOffer(offerIndex: string): Promise; - public abstract setAccount(minterAddress: string): Promise; + public abstract acceptOffer( + offerIndex: string, + isMobile?: boolean + ): Promise; + public abstract setAccount( + minterAddress: string, + isMobile?: boolean + ): Promise; public abstract sendPayment( value: string, destination: string, - memo?: string + memo?: string, + isMobile?: boolean ): Promise; public abstract getAuthData(): Promise | AuthData; } diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 842d83c..95e146b 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -36,7 +36,8 @@ export class XummWalletProvider extends Provider { } private async submitPayload( - tx: SdkTypes.XummJsonTransaction + tx: SdkTypes.XummJsonTransaction, + isMobile?: boolean ): Promise { const callback = async ( event: SdkTypes.SubscriptionCallbackParams @@ -54,12 +55,14 @@ export class XummWalletProvider extends Provider { const subscription = await this.sdk.payload.createAndSubscribe( { txjson: tx, - options: { - return_url: { - app: undefined, // TODO document.location.href, - web: undefined, - }, - }, + options: isMobile + ? { + return_url: { + app: document.location.href, + web: document.location.href, + }, + } + : {}, }, callback ); @@ -69,11 +72,17 @@ export class XummWalletProvider extends Provider { return subscription; } - public async acceptOffer(id: string): Promise { - const subscription = await this.submitPayload({ - TransactionType: "NFTokenAcceptOffer", - NFTokenSellOffer: id, - }); + public async acceptOffer( + id: string, + isMobile?: boolean + ): Promise { + const subscription = await this.submitPayload( + { + TransactionType: "NFTokenAcceptOffer", + NFTokenSellOffer: id, + }, + isMobile + ); return { resolved: _wrap( @@ -84,13 +93,17 @@ export class XummWalletProvider extends Provider { } public async setAccount( - minterAddress: string + minterAddress: string, + isMobile?: boolean ): Promise { - const subscription = await this.submitPayload({ - TransactionType: "AccountSet", - NFTokenMinter: minterAddress, - SetFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, - }); + const subscription = await this.submitPayload( + { + TransactionType: "AccountSet", + NFTokenMinter: minterAddress, + SetFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, + }, + isMobile + ); return { resolved: _wrap( @@ -103,24 +116,28 @@ export class XummWalletProvider extends Provider { public async sendPayment( value: string, destination: string, - memo?: string + memo?: string, + isMobile?: boolean ): Promise { - const subscription = await this.submitPayload({ - TransactionType: "Payment", - Amount: value, - Destination: destination, - Memos: memo - ? [ - { - Memo: { - MemoData: Buffer.from(memo, "utf8") - .toString("hex") - .toUpperCase(), + const subscription = await this.submitPayload( + { + TransactionType: "Payment", + Amount: value, + Destination: destination, + Memos: memo + ? [ + { + Memo: { + MemoData: Buffer.from(memo, "utf8") + .toString("hex") + .toUpperCase(), + }, }, - }, - ] - : [], - }); + ] + : [], + }, + isMobile + ); return { resolved: _wrap( From 9ccb1819880c496c7e43e92399608bfcee12f04d Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 19 Sep 2023 12:27:20 +0200 Subject: [PATCH 084/135] rename menu entry to dashboard --- src/components/Header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 65a0ada..e76e2d5 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -59,7 +59,7 @@ function Header() { () => [ ["Home", "/", false], // ["Attendee", "/attendee", !(isActive && isAuthenticated)], - ["Organizer", "/organizer", !(isActive && isOrganizer)], + ["Dashboard", "/organizer", !(isActive && isOrganizer)], ["Admin", "/admin", !(isActive && isAdmin)], ...(config.debug ? ([["Debug", "/debug", false]] as Array<[string, string, boolean]>) From 8c7ca1c2b74fd47cc1136c9f64d5bd8bf025b49c Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 19 Sep 2023 12:28:10 +0200 Subject: [PATCH 085/135] change to local storage for xumm sdk --- src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 028a3aa..640b862 100644 --- a/src/config.ts +++ b/src/config.ts @@ -26,7 +26,7 @@ const DEFAULT: Config = { apiKey: process.env.REACT_APP_KEY_XUMM_API as string, options: { rememberJwt: true, - storage: window.sessionStorage, + storage: window.localStorage, implicit: true, }, }, From 6ddd9f00b90027757f139ee1331e0a74ef621666 Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 19 Sep 2023 13:24:03 +0200 Subject: [PATCH 086/135] working claim setup --- src/components/ClaimSetup/ClaimSetup.tsx | 114 +++++++++++++++++++--- src/components/ClaimSetup/ClaimStep.tsx | 94 +++++++----------- src/components/ClaimSetup/ConnectStep.tsx | 16 ++- src/components/ClaimSetup/SummaryStep.tsx | 18 ++-- src/pages/ClaimPage.tsx | 11 +-- 5 files changed, 158 insertions(+), 95 deletions(-) diff --git a/src/components/ClaimSetup/ClaimSetup.tsx b/src/components/ClaimSetup/ClaimSetup.tsx index 1d8534a..6a50754 100644 --- a/src/components/ClaimSetup/ClaimSetup.tsx +++ b/src/components/ClaimSetup/ClaimSetup.tsx @@ -1,11 +1,24 @@ import React from "react"; +import { useParams } from "react-router-dom"; import { useAtom } from "jotai"; import { create } from "zustand"; import { isMobile } from "react-device-detect"; -import { Alert, Box, Collapse, Step, StepLabel, Stepper } from "@mui/material"; +import { + Alert, + Box, + Collapse, + IconButton, + Step, + StepLabel, + Stepper, + Tooltip, + Typography, +} from "@mui/material"; +import ChangeCircleIcon from "@mui/icons-material/ChangeCircle"; import { useAuth } from "components/AuthContext"; +import { useWeb3 } from "connectors/context"; import { selectedWalletAtom } from "states/atoms"; import { xumm } from "connectors/xumm"; import { getConnector } from "connectors"; @@ -88,23 +101,26 @@ type StepInfo = { const stepInfo: StepInfo[] = [ { id: Steps.CONNECT, - label: "Connect Wallet", + label: "Connect", }, { id: Steps.CLAIM, - label: "Claim NFT", + label: "Claim", }, { id: Steps.SUMMARY, - label: "Finished", + label: "Finish", }, ]; function ClaimSetup() { const state = useStore(); - const { isAuto, toggleAuto, setClaimFlow } = useAuth(); + const { connector, isActive } = useWeb3(); + const { isAuto, toggleAuto } = useAuth(); const [selectedWallet, setSelectedWallet] = useAtom(selectedWalletAtom); + const { id } = useParams(); + // force enable auto login React.useEffect(() => { if (!isAuto) { @@ -138,7 +154,6 @@ function ClaimSetup() { try { await xumm.activate(); if (mounted) { - state.setError(Steps.CONNECT, null); setSelectedWallet(ConnectorType.XUMM); } } catch (err) { @@ -162,6 +177,24 @@ function ClaimSetup() { }; }, []); + const handleDisconnect = React.useCallback(async () => { + try { + if (connector?.deactivate) { + await connector.deactivate(); + } else { + await connector?.reset(); + } + setSelectedWallet(ConnectorType.EMPTY); + state.reset(); + } catch (err) { + console.debug(err); + state.setError( + Steps.CONNECT, + `Failed to disconnect wallet: ${(err as Error).message}` + ); + } + }, [connector, setSelectedWallet, state.reset]); + // update active step React.useEffect(() => { for (const step of stepInfo) { @@ -187,25 +220,76 @@ function ClaimSetup() { return ( - {state.error[state.activeStep]} + + {state.error[state.activeStep]} + + {[Steps.CLAIM, Steps.SUMMARY].includes(state.activeStep) && ( + + theme.palette.grey[500], + }} + size="small" + onClick={handleDisconnect} + disabled={state.loading} + > + + + + )} + + + Claim Your Event NFT + + + Event ID: {id} + + - - {stepInfo.map((step, index) => ( - - {step.label} - - ))} - + + {stepInfo.map((step, index) => ( + + {step.label} + + ))} + ); } diff --git a/src/components/ClaimSetup/ClaimStep.tsx b/src/components/ClaimSetup/ClaimStep.tsx index 5d0c0ec..f4d2b7f 100644 --- a/src/components/ClaimSetup/ClaimStep.tsx +++ b/src/components/ClaimSetup/ClaimStep.tsx @@ -3,7 +3,7 @@ import { useParams } from "react-router-dom"; import axios from "axios"; import { isMobile } from "react-device-detect"; -import { Box, Button } from "@mui/material"; +import { Box, Button, Typography } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; import API from "apis"; @@ -20,7 +20,7 @@ function SummaryStep({ setComplete, setLoading, }: StepProps) { - const { isActive, account, provider, networkId } = useWeb3(); + const { account, provider } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); const [uuid, setUuid] = React.useState(); @@ -79,8 +79,6 @@ function SummaryStep({ }; }, [active, isAuthorized, jwt, id, count]); - // TODO make useCallback - // TODO change account -> isActive ? const handleClaim = React.useCallback( async (event: React.MouseEvent) => { setLoading(true); @@ -95,59 +93,42 @@ function SummaryStep({ createOffer: true, }); console.debug("JoinResult", offer); - // enqueueSnackbar(`Sign-up successful: Event #${id}`, { - // variant: "success", - // }); setData(offer); } // claim nft if (offer?.offerIndex && !offer?.claimed) { - // TODO this might actually be helpful - // enqueueSnackbar( - // "Creating NFT claim request (confirm the transaction in your wallet)", - // { - // variant: "warning", - // autoHideDuration: 30000, - // } - // ); - const result = await provider.acceptOffer(offer.offerIndex); - + const result = await provider.acceptOffer( + offer.offerIndex, + isMobile + ); setUuid(result.uuid); + // open app + if (isMobile) { + window.location.href = `xumm://xumm.app/sign/${result.uuid}/deeplink`; + } + const txHash = await result.resolved; - console.log("txHash", txHash); - - if (txHash) { - // enqueueSnackbar("Claim successful", { - // variant: "success", - // }); - // TODO trigger accepted to reload claim info - - // force update claim info - setCount((c) => c + 1); - - // bad - // offer.claimed = true; - // setData(offer); - } else { - setError(`Claim failed: Unable to claim NFT`); + + if (!txHash) { setUuid(undefined); + throw Error("Transaction rejected"); } + + // force update claim info + setCount((c) => c + 1); } } } catch (err) { console.debug(err); if (axios.isAxiosError(err)) { - setError(`Sign-up failed: ${err.response?.data.error}`); + setError(`Claim failed: ${err.response?.data.error}`); } else { - setError(`Sign-up failed: ${(err as Error).message}`); + setError(`Claim failed: ${(err as Error).message}`); } } finally { - console.log("Finally being called"); setLoading(false); - // setData(undefined); - // setActiveDialog({}); } }, [provider, account, id, jwt, data] @@ -161,39 +142,34 @@ function SummaryStep({ alignItems: "center", }} > + + Claim your NFT by signing an NFT offer acceptance transaction. + + - {isMobile && uuid && ( - - - - )} - diff --git a/src/components/ClaimSetup/ConnectStep.tsx b/src/components/ClaimSetup/ConnectStep.tsx index cd3ec01..6078e62 100644 --- a/src/components/ClaimSetup/ConnectStep.tsx +++ b/src/components/ClaimSetup/ConnectStep.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useAtom } from "jotai"; -import { Box, Button } from "@mui/material"; +import { Box, Button, Typography } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; import { useAuth } from "components/AuthContext"; @@ -77,10 +77,22 @@ function ConnectStep({ display: "flex", flexDirection: "column", alignItems: "center", + justifyContent: "center", }} > + + Connect your Xumm wallet to start the NFT claiming process. + + - } - > + + + + Events Overview + + + + + + + ); From dd2d5dbee5ff832f376b12cff6010d42b135051b Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 25 Sep 2023 08:43:29 +0200 Subject: [PATCH 090/135] rework event info page --- src/pages/EventInfoPage.tsx | 178 ++++++++++++------------------------ 1 file changed, 59 insertions(+), 119 deletions(-) diff --git a/src/pages/EventInfoPage.tsx b/src/pages/EventInfoPage.tsx index 983447d..ad976bd 100644 --- a/src/pages/EventInfoPage.tsx +++ b/src/pages/EventInfoPage.tsx @@ -3,14 +3,20 @@ import { useParams, useNavigate } from "react-router-dom"; import axios from "axios"; import { useSnackbar } from "notistack"; -import Box from "@mui/material/Box"; -import IconButton from "@mui/material/IconButton"; -import Typography from "@mui/material/Typography"; +import { + Box, + Card, + CardContent, + CardHeader, + CardMedia, + Container, + IconButton, + Typography, +} from "@mui/material"; import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; import API from "apis"; -import type { Event, Metadata } from "types"; -import Loader from "components/Loader"; +import type { Event } from "types"; import { useAuth } from "components/AuthContext"; import AttendanceTable, { AttendanceTableRow } from "components/AttendeeTable"; @@ -19,7 +25,6 @@ import ContentWrapper from "components/ContentWrapper"; function EventInfoPage() { const { jwt } = useAuth(); const [data, setData] = React.useState(); - const [metadata, setMetadata] = React.useState(); const { enqueueSnackbar } = useSnackbar(); const { id } = useParams(); @@ -69,50 +74,6 @@ function EventInfoPage() { }; }, [id, jwt]); - React.useEffect(() => { - let mounted = true; - - const load = async () => { - try { - const response = await axios({ - method: "get", - url: data?.uri, - }); - - if (mounted) { - setMetadata(response.data as Metadata); - } - } catch (err) { - console.error(err); - if (mounted) { - setMetadata(undefined); - } - if (axios.isAxiosError(err)) { - enqueueSnackbar( - `Failed to load event metadata: ${err.response?.data.error}`, - { - variant: "error", - } - ); - } else { - enqueueSnackbar("Failed to load event metadata", { - variant: "error", - }); - } - } - }; - - if (data) { - load(); - } else { - setMetadata(undefined); - } - - return () => { - mounted = false; - }; - }, [data]); - const rows = React.useMemo(() => { if (data && data.attendees) { return data.attendees.map((a, i) => ({ @@ -122,6 +83,9 @@ function EventInfoPage() { firstName: a.firstName, lastName: a.lastName, email: a.email, + // TODO + // tokenId: + // claimed: })); } else { return []; @@ -129,60 +93,39 @@ function EventInfoPage() { }, [data]); return ( - navigate(-1)} - > - - - } - > - {data ? ( - - + navigate(-1)} > - - {data.title} - - {`Event #${data.id}`} - - - {metadata ? ( - + + + } + > + {data ? ( + + + + - - {metadata.description} + + Description: + + {data.description} Date Start:{" "} - {new Date(metadata.dateStart).toString()} + {new Date(data.dateStart).toString()} - Date End:{" "} - {new Date(metadata.dateEnd).toString()} + Date End: {new Date(data.dateEnd).toString()} - Location: {metadata.location} + Location: {data.location} Reserved slots: {data.attendees?.length}/ - {metadata.tokenCount} + {data.tokenCount} - - ) : ( - - )} - - - Attendees: - - - - - ) : ( - // data === null - Event not found! - )} - + + + Attendees: + + + + + + ) : ( + // data === null + Event not found! + )} + + ); } From db9134013dc78b7ed1068e5a5bda94231ae1ecb2 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 25 Sep 2023 08:46:05 +0200 Subject: [PATCH 091/135] fix tooltip --- src/pages/OrganizerPage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index f13ec46..d7eeb08 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -91,7 +91,6 @@ function OrganizerPage() { + + +
    ); } diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 95e146b..31dec3a 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -52,14 +52,17 @@ export class XummWalletProvider extends Provider { } }; + const url = new URL(document.location.href); + url.searchParams.set("connect", "1"); + const subscription = await this.sdk.payload.createAndSubscribe( { txjson: tx, options: isMobile ? { return_url: { - app: document.location.href, - web: document.location.href, + app: url.toString(), + web: url.toString(), }, } : {}, From 129b3e8a1d4fe13c9965b3d15b54e1f985d04711 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 25 Sep 2023 11:07:15 +0200 Subject: [PATCH 094/135] remove obsolete logging --- src/connectors/gem.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index c50fc21..3df7c28 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -149,7 +149,6 @@ export class GemWallet extends Connector { }); Gem.on("walletChanged", async (event) => { - console.log("walletChanged", event.wallet.publicAddress); await this.deactivate(); await this.initProvider(); }); From 95fbec39be780a8a41257b15dc21f43bb34981e6 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 10:10:10 +0200 Subject: [PATCH 095/135] redirect when successfully connecting a wallet --- src/components/Web3Status.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/Web3Status.tsx b/src/components/Web3Status.tsx index f5d46d2..8364e00 100644 --- a/src/components/Web3Status.tsx +++ b/src/components/Web3Status.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useNavigate } from "react-router-dom"; import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; @@ -51,6 +52,7 @@ function Web3Status() { const [status, setStatus] = React.useState(DEFAULT_STATUS); const [menuAnchor, setMenuAnchor] = React.useState(null); const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); // eagerly connect let selectedConnector: Connector | undefined; @@ -102,6 +104,7 @@ function Web3Status() { await connector?.reset(); } setSelectedWallet(ConnectorType.EMPTY); + navigate("/"); } catch (error) { enqueueSnackbar( `Failed to disconnect wallet: ${(error as Error).message}`, @@ -113,6 +116,7 @@ function Web3Status() { try { await xumm.activate(); setSelectedWallet(ConnectorType.XUMM); + navigate("/organizer"); } catch (error) { enqueueSnackbar( `Failed to connect wallet: ${(error as Error).message}`, @@ -124,6 +128,7 @@ function Web3Status() { try { await gem.activate(); setSelectedWallet(ConnectorType.GEM); + navigate("/organizer"); } catch (error) { enqueueSnackbar( `Failed to connect wallet: ${(error as Error).message}`, From 2b7c4e6189b8e43bbc655edaf36df68a7c920059 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 10:18:40 +0200 Subject: [PATCH 096/135] capitalize readme bullets --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ddd5999..88f76ed 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ The web app for Proof of Attendance Protocol - Running POAP API 2.0 server (backend), for details see [here](https://github.com/rikublock/POAP-API2) ## Getting Started -- install dependencies with `yarn install` -- rename `.env.example` to `.env` (change values as needed) +- Install dependencies with `yarn install` +- Rename `.env.example` to `.env` (change values as needed) - `REACT_APP_URL_POAP_API` (backend server URL) - `REACT_APP_KEY_XUMM_API` (Xumm App API key, needs to match the key configured in the backend) -- ensure the backend service is running -- run the app with `yarn start` +- Ensure the backend service is running +- Run the app with `yarn start` ## Available Scripts From ad825fb0895b45a51a05781968b548cb46596eac Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 11:04:02 +0200 Subject: [PATCH 097/135] prompt user to look at their wallet --- src/components/ClaimSetup/ClaimStep.tsx | 9 +++++++++ src/components/CreateDialog/AuthorizationStep.tsx | 8 +++++++- src/components/CreateDialog/PaymentStep.tsx | 8 +++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/ClaimSetup/ClaimStep.tsx b/src/components/ClaimSetup/ClaimStep.tsx index f4d2b7f..b590120 100644 --- a/src/components/ClaimSetup/ClaimStep.tsx +++ b/src/components/ClaimSetup/ClaimStep.tsx @@ -2,6 +2,7 @@ import React from "react"; import { useParams } from "react-router-dom"; import axios from "axios"; import { isMobile } from "react-device-detect"; +import { useSnackbar } from "notistack"; import { Box, Button, Typography } from "@mui/material"; import CircularProgress from "@mui/material/CircularProgress"; @@ -25,6 +26,7 @@ function SummaryStep({ const [data, setData] = React.useState(); const [uuid, setUuid] = React.useState(); const [count, setCount] = React.useState(0); + const { enqueueSnackbar } = useSnackbar(); const { id } = useParams(); @@ -107,6 +109,13 @@ function SummaryStep({ // open app if (isMobile) { window.location.href = `xumm://xumm.app/sign/${result.uuid}/deeplink`; + } else { + enqueueSnackbar( + "Creating payload (confirm the transaction in your wallet)", + { + variant: "info", + } + ); } const txHash = await result.resolved; diff --git a/src/components/CreateDialog/AuthorizationStep.tsx b/src/components/CreateDialog/AuthorizationStep.tsx index 44b21f3..9c4ed08 100644 --- a/src/components/CreateDialog/AuthorizationStep.tsx +++ b/src/components/CreateDialog/AuthorizationStep.tsx @@ -94,8 +94,14 @@ function AuthorizationStep({ try { if (provider && data?.walletAddress) { const result = await provider.setAccount(data.walletAddress); - const txHash = await result.resolved; + enqueueSnackbar( + "Creating payload (confirm the transaction in your wallet)", + { + variant: "info", + } + ); + const txHash = await result.resolved; if (!txHash) { throw Error("Transaction rejected"); } diff --git a/src/components/CreateDialog/PaymentStep.tsx b/src/components/CreateDialog/PaymentStep.tsx index a3b1998..3bf87d1 100644 --- a/src/components/CreateDialog/PaymentStep.tsx +++ b/src/components/CreateDialog/PaymentStep.tsx @@ -106,8 +106,14 @@ function PaymentStep({ data.accounting.depositAddress, `deposit event ${eventId}` ); - const txHash = await result.resolved; + enqueueSnackbar( + "Creating payload (confirm the transaction in your wallet)", + { + variant: "info", + } + ); + const txHash = await result.resolved; if (!txHash) { throw Error("Transaction rejected"); } From 9e28efcfa2c6ad6e3a71d93f865b97e2152e6c81 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 12:21:47 +0200 Subject: [PATCH 098/135] display message when there are no events --- src/pages/OrganizerPage.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/pages/OrganizerPage.tsx b/src/pages/OrganizerPage.tsx index d7eeb08..a9fa34d 100644 --- a/src/pages/OrganizerPage.tsx +++ b/src/pages/OrganizerPage.tsx @@ -3,7 +3,7 @@ import axios from "axios"; import { useSnackbar } from "notistack"; import { useAtom } from "jotai"; -import { Button, Grid, Tooltip, Typography } from "@mui/material"; +import { Box, Button, Grid, Tooltip, Typography } from "@mui/material"; import API from "apis"; import { useWeb3 } from "connectors/context"; @@ -101,7 +101,25 @@ function OrganizerPage() { - + {data ? ( + data.length > 0 ? ( + + ) : ( + + + No Event Data + + + ) + ) : null} ); } From 86e2bdf6d45c114274f530a5311b98b33279e51f Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 12:34:47 +0200 Subject: [PATCH 099/135] add debug clear storage --- src/pages/DebugPage.tsx | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/pages/DebugPage.tsx b/src/pages/DebugPage.tsx index 4e1ca52..a1746a1 100644 --- a/src/pages/DebugPage.tsx +++ b/src/pages/DebugPage.tsx @@ -1,7 +1,23 @@ import React from "react"; +import { Box, Button, Stack } from "@mui/material"; + +const clearStorage = async () => { + console.log("Clearing storages"); + window.localStorage.clear(); + window.sessionStorage.clear(); +}; + function DebugPage() { - return ; + return ( + + + + + + ); } export default DebugPage; From a4a50e299e8d8ee2f63ddad4d47bde19b09d6df9 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 11:59:30 +0200 Subject: [PATCH 100/135] rework home page --- custom.d.ts | 11 +++++++++++ package.json | 1 + src/assets/LICENSE | 8 ++++++++ src/assets/connected.svg | 1 + src/pages/HomePage.tsx | 25 +++++++++---------------- tsconfig.json | 2 +- webpack.config.js | 13 ++++++++++++- 7 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 custom.d.ts create mode 100644 src/assets/LICENSE create mode 100644 src/assets/connected.svg diff --git a/custom.d.ts b/custom.d.ts new file mode 100644 index 0000000..5cdace4 --- /dev/null +++ b/custom.d.ts @@ -0,0 +1,11 @@ +declare module "*.svg" { + import * as React from "react"; + + const content: React.FunctionComponent>; + export default content; +} + +declare module "*.svg?url" { + const content: string; + export default content; +} diff --git a/package.json b/package.json index 1ceeec3..99652c5 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ ] }, "devDependencies": { + "@svgr/webpack": "^8.1.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", diff --git a/src/assets/LICENSE b/src/assets/LICENSE new file mode 100644 index 0000000..c6fdf88 --- /dev/null +++ b/src/assets/LICENSE @@ -0,0 +1,8 @@ +Copyright 2023 Katerina Limpitsouni + +All images, assets and vectors published on unDraw can be used for free. You can use them for noncommercial and commercial purposes. You do not need to ask permission from or provide credit to the creator or unDraw. + +More precisely, unDraw grants you an nonexclusive, worldwide copyright license to download, copy, modify, distribute, perform, and use the assets provided from unDraw for free, including for commercial purposes, without permission from or attributing the creator or unDraw. This license does not include the right to compile assets, vectors or images from unDraw to replicate a similar or competing service, in any form or distribute the assets in packs or otherwise. This extends to automated and non-automated ways to link, embed, scrape, search or download the assets included on the website without our consent. +Regarding brand logos that are included: + +Are registered trademarks of their respected owners. Are included on a promotional basis and do not represent an association with unDraw or its users. Do not indicate any kind of endorsement of the trademark holder towards unDraw, nor vice versa. Are provided with the sole purpose to represent the actual brand/service/company that has registered the trademark and must not be used otherwise. diff --git a/src/assets/connected.svg b/src/assets/connected.svg new file mode 100644 index 0000000..45bae4c --- /dev/null +++ b/src/assets/connected.svg @@ -0,0 +1 @@ +connected world \ No newline at end of file diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index afe1e5c..95a19ab 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,6 +1,7 @@ import React from "react"; -import { Card, CardContent, Grid, Typography } from "@mui/material"; +import { Box, Card, CardContent, Grid, Typography } from "@mui/material"; +import ConnectedSvg from "assets/connected.svg"; import ContentWrapper from "components/ContentWrapper"; import Web3Status from "components/Web3Status"; @@ -20,25 +21,11 @@ const features: Feature[] = [ title: "Easy to use", description: "POAP is designed from the ground up to be easy to use.", }, - { - title: "Trust and Transparency", - description: - "Build trust with your attendees by offering a verifiable Proof of Attendance, enhancing the credibility of your events.", - }, - { - title: "Non-Fungible Token (NFT)", - description: - "These tokens serve as evidence that an individual attended a particular event.", - }, { title: "Effortless Verification", description: "Our PoA system automates the attendance tracking process, making it quick and hassle-free.", }, - { - title: "TODO", - description: "any ideas?", - }, ]; function HomePage() { @@ -65,7 +52,13 @@ function HomePage() { - TODO Image? + + + diff --git a/tsconfig.json b/tsconfig.json index ff107d6..5020513 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ "strict": true, "target": "es5" }, - "include": ["src"] + "include": ["custom.d.ts", "src/**/*"] } diff --git a/webpack.config.js b/webpack.config.js index 173116c..31b2441 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -45,9 +45,20 @@ module.exports = { use: ["style-loader", "css-loader"], }, { - test: /\.(png|svg|jpg|jpeg|gif|ico)$/i, + test: /\.(png|jpg|jpeg|gif|ico)$/i, type: "asset/resource", }, + { + test: /\.svg$/i, + type: "asset/resource", + resourceQuery: /url/, // *.svg?url + }, + { + test: /\.svg$/i, + issuer: /\.[jt]sx?$/i, + resourceQuery: { not: [/url/] }, // exclude react component if *.svg?url + use: ["@svgr/webpack"], + }, ], }, optimization: { From 36f6863be775640ea163e5ecf29e58a2884be81b Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 13:28:01 +0200 Subject: [PATCH 101/135] add back button component --- src/components/BackButton.tsx | 19 +++++++++++++++++++ src/pages/EventInfoPage.tsx | 16 +++------------- 2 files changed, 22 insertions(+), 13 deletions(-) create mode 100644 src/components/BackButton.tsx diff --git a/src/components/BackButton.tsx b/src/components/BackButton.tsx new file mode 100644 index 0000000..64c48b7 --- /dev/null +++ b/src/components/BackButton.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; + +import { IconButton, Tooltip } from "@mui/material"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; + +function BackButton() { + const navigate = useNavigate(); + + return ( + + navigate(-1)}> + + + + ); +} + +export default BackButton; diff --git a/src/pages/EventInfoPage.tsx b/src/pages/EventInfoPage.tsx index ad976bd..54f41fd 100644 --- a/src/pages/EventInfoPage.tsx +++ b/src/pages/EventInfoPage.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams } from "react-router-dom"; import axios from "axios"; import { useSnackbar } from "notistack"; @@ -10,10 +10,8 @@ import { CardHeader, CardMedia, Container, - IconButton, Typography, } from "@mui/material"; -import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; import API from "apis"; import type { Event } from "types"; @@ -21,6 +19,7 @@ import type { Event } from "types"; import { useAuth } from "components/AuthContext"; import AttendanceTable, { AttendanceTableRow } from "components/AttendeeTable"; import ContentWrapper from "components/ContentWrapper"; +import BackButton from "components/BackButton"; function EventInfoPage() { const { jwt } = useAuth(); @@ -28,7 +27,6 @@ function EventInfoPage() { const { enqueueSnackbar } = useSnackbar(); const { id } = useParams(); - const navigate = useNavigate(); React.useEffect(() => { let mounted = true; @@ -97,15 +95,7 @@ function EventInfoPage() { navigate(-1)} - > - - - } + secondary={} > {data ? ( From d0fe49043d898330c0b3fead638a87c137c18d59 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 16:05:32 +0200 Subject: [PATCH 102/135] add offset prop --- src/components/ContentWrapper.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/components/ContentWrapper.tsx b/src/components/ContentWrapper.tsx index c3fdf0f..3872793 100644 --- a/src/components/ContentWrapper.tsx +++ b/src/components/ContentWrapper.tsx @@ -9,6 +9,7 @@ export type ContentWrapperProps = { children?: ReactNode; tooltip?: ReactNode; secondary?: ReactNode; + offsetSecondaryTop?: string; isLoading?: boolean; isAuthorized?: boolean; }; @@ -17,12 +18,19 @@ export function ContentWrapper({ children, tooltip, secondary, + offsetSecondaryTop, isLoading, isAuthorized, }: ContentWrapperProps) { return ( - + {secondary} {tooltip && ( From 899849518d6a38a1da39f929ad360ba3aded17bb Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 16:06:24 +0200 Subject: [PATCH 103/135] disable obsolete actions --- src/components/EventTable.tsx | 45 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index 11636eb..ab076be 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -18,7 +18,6 @@ import { GridValueGetterParams, } from "@mui/x-data-grid"; -import { useWeb3 } from "connectors/context"; import { DialogIdentifier, EventStatus } from "types"; import DataTable from "components/DataTable"; import { activeDialogAtom } from "states/atoms"; @@ -192,34 +191,34 @@ export function EventTable({ rows }: EventTableProps) { EventStatus.ACTIVE, ].includes(params.row.status); return [ - } - label="Add Participant" - onClick={() => handleAdd(params.row.id)} - disabled={!active} - showInMenu - />, + // } + // label="Add Participant" + // onClick={() => handleAdd(params.row.id)} + // disabled={!active} + // showInMenu + // />, } label="Create Link" onClick={() => handleLink(params.row.id)} - disabled={!active} - showInMenu - />, - } - label="Join Event" - onClick={() => handleJoin(params.row.id, params.row.title)} - disabled={!active} - showInMenu - />, - } - label="Claim NFT" - onClick={() => handleClaim(params.row.id)} - disabled={!active} + disabled={true} showInMenu />, + // } + // label="Join Event" + // onClick={() => handleJoin(params.row.id, params.row.title)} + // disabled={!active} + // showInMenu + // />, + // } + // label="Claim NFT" + // onClick={() => handleClaim(params.row.id)} + // disabled={!active} + // showInMenu + // />, } label="Cancel Event" From 01dca31c119150dff4f2eb55a0ed8ab8a1145eeb Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 16:07:16 +0200 Subject: [PATCH 104/135] rework admin pages --- src/components/EventTable.tsx | 2 +- src/components/UserTable.tsx | 72 ++++------ src/layouts/AdminLayout.tsx | 101 -------------- src/pages/AdminEventsPage.tsx | 34 +++-- src/pages/AdminPage.tsx | 235 +++++++++++++++++++++++++++++++++ src/pages/AdminStatsPage.tsx | 241 ---------------------------------- src/pages/AdminUsersPage.tsx | 57 +++++--- src/routes/AdminRoutes.tsx | 36 ----- src/routes/MainRoutes.tsx | 15 +++ src/routes/index.ts | 3 +- src/types.ts | 1 + 11 files changed, 344 insertions(+), 453 deletions(-) delete mode 100644 src/layouts/AdminLayout.tsx create mode 100644 src/pages/AdminPage.tsx delete mode 100644 src/pages/AdminStatsPage.tsx delete mode 100644 src/routes/AdminRoutes.tsx diff --git a/src/components/EventTable.tsx b/src/components/EventTable.tsx index ab076be..9ae589f 100644 --- a/src/components/EventTable.tsx +++ b/src/components/EventTable.tsx @@ -149,7 +149,7 @@ export function EventTable({ rows }: EventTableProps) { field: "slots", headerName: "Slots", type: "number", - width: 60, + width: 80, valueGetter: ({ row }: GetterParamsType) => { if (row.slotsTaken !== undefined) { return `${row.slotsTaken}/${row.slotsTotal}`; diff --git a/src/components/UserTable.tsx b/src/components/UserTable.tsx index 7225a13..4c87f5f 100644 --- a/src/components/UserTable.tsx +++ b/src/components/UserTable.tsx @@ -1,12 +1,7 @@ import React from "react"; import clsx from "clsx"; -import { - GridColDef, - GridRowClassNameParams, - GridTreeNodeWithRender, - GridValueGetterParams, -} from "@mui/x-data-grid"; +import { GridColDef, GridRowClassNameParams } from "@mui/x-data-grid"; import DataTable from "components/DataTable"; @@ -15,6 +10,7 @@ export type UserTableRow = { walletAddress: string; isOrganizer: boolean; eventCount?: number; + eventActiveCount?: number; totalDeposit?: string; }; @@ -22,11 +18,32 @@ export type EventTableProps = { rows: UserTableRow[]; }; -type GetterParamsType = GridValueGetterParams< - UserTableRow, - any, - GridTreeNodeWithRender ->; +const columns: GridColDef[] = [ + { + field: "walletAddress", + headerName: "Wallet Address", + type: "string", + width: 320, + }, + { + field: "eventCount", + headerName: "# Total Events", + type: "number", + width: 150, + }, + { + field: "eventActiveCount", + headerName: "# Active Events", + type: "number", + width: 150, + }, + { + field: "totalDeposit", + headerName: "Total XRP Deposit", + type: "number", + width: 170, + }, +]; export function UserTable({ rows }: EventTableProps) { const rowClassName = (params: GridRowClassNameParams) => { @@ -35,39 +52,6 @@ export function UserTable({ rows }: EventTableProps) { }); }; - const columns = React.useMemo[]>( - () => [ - { - field: "walletAddress", - headerName: "Wallet Address", - type: "string", - width: 320, - }, - { - field: "eventCount", - headerName: "# Events", - type: "number", - width: 110, - }, - { - field: "totalDeposit", - headerName: "Total XRP Deposit", - type: "number", - width: 170, - }, - { - field: "actions", - type: "actions", - width: 45, - minWidth: 45, - getActions: (params) => { - return []; - }, - }, - ], - [] - ); - return ( , to: "/admin/stats" }, - { label: "Organizers", icon: , to: "/admin/users" }, - { label: "Events", icon: , to: "/admin/events" }, - ]; - - return ( - -
    - - - - theme.palette.primary.dark, - }, - }, - }} - > - {entries.map((entry, index) => ( - - - {entry.icon} - - - - ))} - - - - - - - - - - - ); -} - -export default AdminLayout; diff --git a/src/pages/AdminEventsPage.tsx b/src/pages/AdminEventsPage.tsx index 70233fa..383c7e5 100644 --- a/src/pages/AdminEventsPage.tsx +++ b/src/pages/AdminEventsPage.tsx @@ -1,19 +1,25 @@ import React from "react"; import axios from "axios"; +import { useAtomValue } from "jotai"; import { dropsToXrp } from "xrpl"; import { useSnackbar } from "notistack"; +import { Typography } from "@mui/material"; + import API from "apis"; import { useWeb3 } from "connectors/context"; import type { Event } from "types"; +import { activeDialogAtom } from "states/atoms"; import EventTable, { type EventTableRow } from "components/EventTable"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; +import BackButton from "components/BackButton"; function AdminEventsPage() { const { networkId } = useWeb3(); const { isAuthenticated, jwt, permissions } = useAuth(); const [data, setData] = React.useState(); + const activeDialog = useAtomValue(activeDialogAtom); const { enqueueSnackbar } = useSnackbar(); const isAuthorized = React.useMemo(() => { @@ -48,23 +54,29 @@ function AdminEventsPage() { } ); } else { - enqueueSnackbar("Failed to load events data", { - variant: "error", - }); + enqueueSnackbar( + `Failed to load events data: ${(err as Error).message}`, + { + variant: "error", + } + ); } } }; - if (isAuthorized) { - load(); - } else { - setData(undefined); + // only update data, if no dialog is open + if (!activeDialog.type) { + if (isAuthorized) { + load(); + } else { + setData(undefined); + } } return () => { mounted = false; }; - }, [isAuthorized, networkId, jwt]); + }, [activeDialog, isAuthorized, networkId, jwt]); const rows = React.useMemo(() => { if (data) { @@ -92,10 +104,14 @@ function AdminEventsPage() { return ( } + offsetSecondaryTop="0rem" > + + Events Overview + ); diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx new file mode 100644 index 0000000..7a5c708 --- /dev/null +++ b/src/pages/AdminPage.tsx @@ -0,0 +1,235 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import axios from "axios"; +import { dropsToXrp } from "xrpl"; +import { useSnackbar } from "notistack"; + +import { + Box, + Button, + Grid, + Typography, + Card, + CardContent, + CardActions, +} from "@mui/material"; + +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import type { PlatformStats } from "types"; +import ContentWrapper from "components/ContentWrapper"; +import { useAuth } from "components/AuthContext"; +import PieChart from "components/PieChart"; + +type Info = { + id: string; + title: string; + value: string | number; + to?: string; +}; + +function AdminPage() { + const { networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [data, setData] = React.useState(); + const { enqueueSnackbar } = useSnackbar(); + const navigate = useNavigate(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("admin"); + }, [isAuthenticated, permissions]); + + // live updating stats + React.useEffect(() => { + let mounted = true; + + const load = async () => { + try { + if (networkId && jwt) { + const stats = await API.admin.getStats(jwt, { + networkId: networkId, + }); + + if (mounted) { + setData(stats); + } + } + } catch (err) { + console.debug(err); + if (mounted) { + setData(undefined); + } + if (axios.isAxiosError(err)) { + enqueueSnackbar( + `Failed to load stats data: ${err.response?.data.error}`, + { + variant: "error", + } + ); + } else { + enqueueSnackbar( + `Failed to load stats data: ${(err as Error).message}`, + { + variant: "error", + } + ); + } + } + }; + + const check = () => { + if (isAuthorized) { + load(); + } else { + setData(undefined); + } + }; + + const interval = setInterval(check, 15 * 1000); + check(); + + // clear interval to prevent memory leaks when unmounting + return () => { + clearInterval(interval); + mounted = false; + }; + }, [isAuthorized, networkId, jwt]); + + const stats: Info[] = React.useMemo(() => { + if (data) { + return [ + { + id: "organizers", + title: "Total Organizers", + value: data.users.organizers, + to: "/admin/users", + }, + { + id: "events", + title: "Total Events", + value: data.events.total, + to: "/admin/events", + }, + { + id: "balance", + title: "Total Vault Balance", + value: `${dropsToXrp(data.account.balance)} XRP`, + }, + { + id: "reserve", + title: "Total Vault Reserve", + value: `${dropsToXrp(data.account.reserve)} XRP`, + }, + ]; + } else { + return []; + } + }, [data]); + + return ( + + + + Platform Stats + + {stats.map((info, index) => ( + + + + + {info.title} + + + {info.value} + + {/* + subtitle + + some text */} + + {info.to && ( + + + + )} + + + ))} + + + + {data && ( + + + + + Event Stats + + + + + + + + + + + + + + + Account Stats + + + + + + + + + + + + + )} + + + ); +} + +export default AdminPage; diff --git a/src/pages/AdminStatsPage.tsx b/src/pages/AdminStatsPage.tsx deleted file mode 100644 index 22a25cd..0000000 --- a/src/pages/AdminStatsPage.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import React from "react"; -import axios from "axios"; -import { useSnackbar } from "notistack"; - -import { - Box, - Button, - Grid, - Typography, - Card, - CardContent, - CardActions, -} from "@mui/material"; - -import API from "apis"; -import { useWeb3 } from "connectors/context"; -import type { PlatformStats } from "types"; -import ContentWrapper from "components/ContentWrapper"; -import { useAuth } from "components/AuthContext"; -import PieChart from "components/PieChart"; - -function AdminStatsPage() { - const { isActive, networkId } = useWeb3(); - const { isAuthenticated, jwt, permissions } = useAuth(); - const [data, setData] = React.useState(); - const { enqueueSnackbar } = useSnackbar(); - - const isAuthorized = React.useMemo(() => { - return isAuthenticated && permissions.includes("admin"); - }, [isAuthenticated, permissions]); - - // TODO add live updating on 10s timer ? - React.useEffect(() => { - let mounted = true; - - const load = async () => { - try { - if (networkId && jwt) { - const stats = await API.admin.getStats(jwt, { - networkId: networkId, - }); - - // TODO - console.log(stats); - - if (mounted) { - setData(stats); - } - } - } catch (err) { - console.debug(err); - if (mounted) { - setData(undefined); - } - if (axios.isAxiosError(err)) { - enqueueSnackbar( - `Failed to load stats data: ${err.response?.data.error}`, - { - variant: "error", - } - ); - } else { - enqueueSnackbar("Failed to load stats data", { - variant: "error", - }); - } - } - }; - - if (isAuthorized) { - load(); - } else { - setData(undefined); - } - - return () => { - mounted = false; - }; - }, [isAuthorized, networkId, jwt]); - - return ( - - - Platform Stats - - - - - - Total Users - - - asdf - - - adjective - - - well meaning and kindly. -
    - {'"a benevolent smile"'} -
    -
    - - - -
    -
    - - - - - Total Events - - - asdf - - - adjective - - - well meaning and kindly. -
    - {'"a benevolent smile"'} -
    -
    - - - -
    -
    - - - - - Total Balance - - - asdf - - - adjective - - - well meaning and kindly. -
    - {'"a benevolent smile"'} -
    -
    - - - -
    -
    - - - - - Total Reserves - - - asdf - - - adjective - - - well meaning and kindly. -
    - {'"a benevolent smile"'} -
    -
    - - - -
    -
    - - - - - - - Account Balance - - - - - - - - - - - - - - - Account Reserves - - - - - - - - - - - - - ); -} - -export default AdminStatsPage; diff --git a/src/pages/AdminUsersPage.tsx b/src/pages/AdminUsersPage.tsx index 8dbb931..995e429 100644 --- a/src/pages/AdminUsersPage.tsx +++ b/src/pages/AdminUsersPage.tsx @@ -3,11 +3,14 @@ import axios from "axios"; import { dropsToXrp } from "xrpl"; import { useSnackbar } from "notistack"; +import { Typography } from "@mui/material"; + import API from "apis"; import ContentWrapper from "components/ContentWrapper"; import { useAuth } from "components/AuthContext"; -import type { User } from "types"; +import { EventStatus, type User } from "types"; import UserTable, { UserTableRow } from "components/UserTable"; +import BackButton from "components/BackButton"; function AdminUsersPage() { const { isAuthenticated, jwt, permissions } = useAuth(); @@ -25,7 +28,6 @@ function AdminUsersPage() { try { if (jwt) { const users = await API.users.getOrganizers(jwt); - console.log(users); // TODO if (mounted) { setData(users); @@ -44,9 +46,12 @@ function AdminUsersPage() { } ); } else { - enqueueSnackbar("Failed to load users data", { - variant: "error", - }); + enqueueSnackbar( + `Failed to load users data: ${(err as Error).message}`, + { + variant: "error", + } + ); } } }; @@ -64,19 +69,29 @@ function AdminUsersPage() { const rows = React.useMemo(() => { if (data) { - return data.map((user, index) => ({ - id: index, - walletAddress: user.walletAddress, - isOrganizer: user.isOrganizer, - eventCount: user.events?.length, - totalDeposit: dropsToXrp( - user.events?.reduce( - (accumulator, event) => - accumulator + (event.accounting?.depositValue ?? 0), - 0 - ) ?? 0 - ), - })); + return data.map((user, index) => { + const activeEvents = user.events?.filter((x) => + [EventStatus.PAID, EventStatus.ACTIVE].includes(x.status) + ); + return { + id: index, + walletAddress: user.walletAddress, + isOrganizer: user.isOrganizer, + eventCount: user.events?.length, + eventActiveCount: activeEvents?.length, + totalDeposit: dropsToXrp( + ( + activeEvents?.reduce( + (accumulator, event) => + accumulator + + BigInt(event.accounting?.depositReserveValue || "0") + + BigInt(event.accounting?.depositFeeValue || "0"), + BigInt(0) + ) || BigInt(0) + ).toString() + ), + }; + }); } else { return []; } @@ -84,10 +99,14 @@ function AdminUsersPage() { return ( } + offsetSecondaryTop="0rem" > + + Organizers Overview + ); diff --git a/src/routes/AdminRoutes.tsx b/src/routes/AdminRoutes.tsx deleted file mode 100644 index 41599d0..0000000 --- a/src/routes/AdminRoutes.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import { Navigate, type RouteObject } from "react-router-dom"; - -import Loadable from "components/Loadable"; -import AdminLayout from "layouts/AdminLayout"; - -const AdminEventsPage = Loadable(React.lazy(() => import("pages/AdminEventsPage"))); -const AdminUsersPage = Loadable(React.lazy(() => import("pages/AdminUsersPage"))); -const AdminStatsPage = Loadable(React.lazy(() => import("pages/AdminStatsPage"))); -const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); - -const AdminRoutes: RouteObject = { - path: "/", - element: , - errorElement: , - children: [ - { - path: "/admin", - element: , - }, - { - path: "/admin/stats", - element: , - }, - { - path: "/admin/users", - element: , - }, - { - path: "/admin/events", - element: , - }, - ], -}; - -export default AdminRoutes; diff --git a/src/routes/MainRoutes.tsx b/src/routes/MainRoutes.tsx index ffa1aad..2360a31 100644 --- a/src/routes/MainRoutes.tsx +++ b/src/routes/MainRoutes.tsx @@ -4,6 +4,9 @@ import type { RouteObject } from "react-router-dom"; import Loadable from "components/Loadable"; import MainLayout from "layouts/MainLayout"; +const AdminEventsPage = Loadable(React.lazy(() => import("pages/AdminEventsPage"))); +const AdminPage = Loadable(React.lazy(() => import("pages/AdminPage"))); +const AdminUsersPage = Loadable(React.lazy(() => import("pages/AdminUsersPage"))); const DebugPage = Loadable(React.lazy(() => import("pages/DebugPage"))); const ErrorPage = Loadable(React.lazy(() => import("pages/ErrorPage"))); const EventInfoPage = Loadable(React.lazy(() => import("pages/EventInfoPage"))); @@ -27,6 +30,18 @@ const MainRoutes: RouteObject = { path: "/organizer", element: , }, + { + path: "/admin", + element: , + }, + { + path: "/admin/users", + element: , + }, + { + path: "/admin/events", + element: , + }, { path: "/debug", element: , diff --git a/src/routes/index.ts b/src/routes/index.ts index b416bb2..65b548d 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,10 +1,9 @@ import { useRoutes } from "react-router-dom"; -import AdminRoutes from "./AdminRoutes"; import BasicRoutes from "./BasicRoutes"; import DefaultRoutes from "./DefaultRoutes"; import MainRoutes from "./MainRoutes"; export default function Routes() { - return useRoutes([MainRoutes, AdminRoutes, BasicRoutes, DefaultRoutes]); + return useRoutes([MainRoutes, BasicRoutes, DefaultRoutes]); } diff --git a/src/types.ts b/src/types.ts index 95ba71d..0a4ffe0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -120,6 +120,7 @@ export type PlatformStats = { users: { total: number; organizers: number; + admins: number, }; events: { total: number; From d4249dd5676af335e2e01a5f70242e00c5327d31 Mon Sep 17 00:00:00 2001 From: Riku Date: Tue, 3 Oct 2023 08:38:29 +0200 Subject: [PATCH 105/135] add debug button to revoke authorized minter --- src/connectors/gem.ts | 16 +++++--- src/connectors/provider.ts | 2 +- src/connectors/xumm.ts | 17 +++++--- src/pages/DebugPage.tsx | 84 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/connectors/gem.ts b/src/connectors/gem.ts index 3df7c28..5a1f346 100644 --- a/src/connectors/gem.ts +++ b/src/connectors/gem.ts @@ -47,13 +47,19 @@ export class GemWalletProvider extends Provider { } public async setAccount( - minterAddress: string, + minterAddress: string | null, isMobile?: boolean ): Promise { - const response = await Gem.setAccount({ - NFTokenMinter: minterAddress, - setFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, - }); + const response = await Gem.setAccount( + minterAddress + ? { + NFTokenMinter: minterAddress, + setFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, + } + : { + clearFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, + } + ); if (response.type === "reject") { throw Error("User refused to sign AccountSet transaction"); } diff --git a/src/connectors/provider.ts b/src/connectors/provider.ts index 0add835..4ef5724 100644 --- a/src/connectors/provider.ts +++ b/src/connectors/provider.ts @@ -13,7 +13,7 @@ export abstract class Provider { isMobile?: boolean ): Promise; public abstract setAccount( - minterAddress: string, + minterAddress: string | null, isMobile?: boolean ): Promise; public abstract sendPayment( diff --git a/src/connectors/xumm.ts b/src/connectors/xumm.ts index 31dec3a..2426e0d 100644 --- a/src/connectors/xumm.ts +++ b/src/connectors/xumm.ts @@ -96,15 +96,20 @@ export class XummWalletProvider extends Provider { } public async setAccount( - minterAddress: string, + minterAddress: string | null, isMobile?: boolean ): Promise { const subscription = await this.submitPayload( - { - TransactionType: "AccountSet", - NFTokenMinter: minterAddress, - SetFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, - }, + minterAddress + ? { + TransactionType: "AccountSet", + NFTokenMinter: minterAddress, + SetFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, + } + : { + TransactionType: "AccountSet", + ClearFlag: AccountSetAsfFlags.asfAuthorizedNFTokenMinter, + }, isMobile ); diff --git a/src/pages/DebugPage.tsx b/src/pages/DebugPage.tsx index a1746a1..23b3569 100644 --- a/src/pages/DebugPage.tsx +++ b/src/pages/DebugPage.tsx @@ -1,20 +1,98 @@ import React from "react"; +import axios from "axios"; +import { useSnackbar } from "notistack"; import { Box, Button, Stack } from "@mui/material"; +import CircularProgress from "@mui/material/CircularProgress"; -const clearStorage = async () => { +import API from "apis"; +import { useWeb3 } from "connectors/context"; +import { useAuth } from "components/AuthContext"; + +const handleClearStorage = async ( + event: React.MouseEvent +) => { console.log("Clearing storages"); window.localStorage.clear(); window.sessionStorage.clear(); }; function DebugPage() { + const { provider, networkId } = useWeb3(); + const { isAuthenticated, jwt, permissions } = useAuth(); + const [loading, setLoading] = React.useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const isAuthorized = React.useMemo(() => { + return isAuthenticated && permissions.includes("organizer"); + }, [isAuthenticated, permissions]); + + const handleRevokeMinter = React.useCallback( + async (event: React.MouseEvent) => { + setLoading(true); + try { + if (provider && networkId && jwt) { + // fetch authorized minter status + const minter = await API.event.getMinter(jwt, { + networkId: networkId, + }); + + if (!minter.isConfigured) { + return; + } + + // revoke minter + const result = await provider.setAccount(null); + enqueueSnackbar( + "Creating payload (confirm the transaction in your wallet)", + { + variant: "info", + } + ); + + const txHash = await result.resolved; + if (!txHash) { + throw Error("Transaction rejected"); + } + } + } catch (err) { + const msg = "Failed to revoke minter"; + console.debug(err); + if (axios.isAxiosError(err)) { + enqueueSnackbar(`${msg}: ${err.response?.data.error}`, { + variant: "error", + }); + } else { + enqueueSnackbar(`${msg}: ${(err as Error).message}`, { + variant: "error", + }); + } + } finally { + setLoading(false); + } + }, + [networkId, provider, jwt] + ); + return ( - - + ); From 6459cd1c012b7d5ec2fc29afa92ce44b9022f870 Mon Sep 17 00:00:00 2001 From: Riku Date: Mon, 2 Oct 2023 18:50:05 +0200 Subject: [PATCH 106/135] add footer --- package.json | 3 + src/components/ContentWrapper.tsx | 2 +- src/components/Footer.tsx | 99 +++++++++++++++++++++++++++++++ src/components/Header.tsx | 2 +- src/config.ts | 10 ++++ src/layouts/MainLayout.tsx | 5 +- src/pages/HomePage.tsx | 2 +- 7 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 src/components/Footer.tsx diff --git a/package.json b/package.json index 99652c5..4441bce 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "@ant-design/icons": "^5.1.4", "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", + "@fortawesome/fontawesome-svg-core": "^6.4.2", + "@fortawesome/free-brands-svg-icons": "^6.4.2", + "@fortawesome/react-fontawesome": "^0.2.0", "@gemwallet/api": "^3.4.0", "@hookform/resolvers": "^3.1.1", "@mui/icons-material": "^5.11.16", diff --git a/src/components/ContentWrapper.tsx b/src/components/ContentWrapper.tsx index 3872793..2891eba 100644 --- a/src/components/ContentWrapper.tsx +++ b/src/components/ContentWrapper.tsx @@ -23,7 +23,7 @@ export function ContentWrapper({ isAuthorized, }: ContentWrapperProps) { return ( - + + + + © 2023 Proof of Attendance Protocol. All rights reserved. + + + {infos.map((info, index) => ( + theme.palette.primary.light, + }, + }} + > + + + ))} + + + + ); +} + +export default Footer; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index aa2463a..1ca4dde 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -34,7 +34,7 @@ const StyledList = styled(MuiList)<{ component?: React.ElementType }>( "& .MuiListItemButton-root": { padding: "6px 12px", "&:hover": { - color: "#09c1d1", + color: theme.palette.primary.light, }, "&.active": { color: theme.palette.primary.dark, diff --git a/src/config.ts b/src/config.ts index 640b862..c268d47 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,11 @@ type Config = { }; }; }; + socials: { + discordUrl: string; + githubUrl: string; + twitterUrl: string; + } }; const DEFAULT: Config = { @@ -31,6 +36,11 @@ const DEFAULT: Config = { }, }, }, + socials: { + discordUrl: "https://discord.gg/xrpl", + githubUrl: "https://github.com/XRPLBounties/POAP-APP", + twitterUrl: "https://twitter.com/", + } }; const config = DEFAULT; diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 4dc972b..d592b6f 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -14,6 +14,7 @@ import CreateDialog from "components/CreateDialog"; import JoinDialog from "components/JoinDialog"; import LinkDialog from "components/LinkDialog"; import ProfileDialog from "components/ProfileDialog"; +import Footer from "components/Footer"; function MainLayout() { const activeDialog = useAtomValue(activeDialogAtom); @@ -31,7 +32,8 @@ function MainLayout() { > +