diff --git a/package-lock.json b/package-lock.json index e2039377..3c70df44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "sherlock-v2-frontend", "version": "0.1.0", "dependencies": { + "@floating-ui/react": "^0.26.9", "@sentry/react": "^6.19.7", "@sentry/tracing": "^6.19.7", "@testing-library/jest-dom": "^5.16.1", @@ -2951,6 +2952,54 @@ "@ethersproject/strings": "^5.7.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.9.tgz", + "integrity": "sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.8", + "@floating-ui/utils": "^0.2.1", + "tabbable": "^6.0.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "dependencies": { + "@floating-ui/dom": "^1.6.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -19190,6 +19239,11 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/tailwindcss": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.5.tgz", @@ -22948,6 +23002,46 @@ "@ethersproject/strings": "^5.7.0" } }, + "@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "requires": { + "@floating-ui/utils": "^0.2.1" + } + }, + "@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "requires": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "@floating-ui/react": { + "version": "0.26.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.9.tgz", + "integrity": "sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==", + "requires": { + "@floating-ui/react-dom": "^2.0.8", + "@floating-ui/utils": "^0.2.1", + "tabbable": "^6.0.1" + } + }, + "@floating-ui/react-dom": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "requires": { + "@floating-ui/dom": "^1.6.1" + } + }, + "@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", @@ -35052,6 +35146,11 @@ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" }, + "tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "tailwindcss": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.5.tgz", diff --git a/package.json b/package.json index a692f2a7..aa35eab1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "dependencies": { + "@floating-ui/react": "^0.26.9", "@sentry/react": "^6.19.7", "@sentry/tracing": "^6.19.7", "@testing-library/jest-dom": "^5.16.1", diff --git a/src/App.tsx b/src/App.tsx index 35dc326e..58da7613 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,7 +43,7 @@ function App() { ) useEffect(() => { - if (!connectedAddress || (profile && !addressIsAllowed(connectedAddress))) { + if (profile && connectedAddress && !addressIsAllowed(connectedAddress)) { signOut() } }, [connectedAddress, addressIsAllowed, signOut, profile]) diff --git a/src/AppAdmin.tsx b/src/AppAdmin.tsx index 05d68e16..7646369f 100644 --- a/src/AppAdmin.tsx +++ b/src/AppAdmin.tsx @@ -11,20 +11,12 @@ import { Box } from "./components/Box" import { useAdminSignIn } from "./hooks/api/admin/useAdminSignIn" import { ErrorModal } from "./pages/ContestDetails/ErrorModal" import { useAccount } from "wagmi" -import { contests as contestsAPI } from "./hooks/api/axios" -import { adminSignOut as adminSignOutUrl } from "./hooks/api/urls" const AppInternal = () => { const { data: adminAddress } = useAdminProfile() - const { address: connectedAddress, isDisconnected } = useAccount() + const { address: connectedAddress } = useAccount() const { signIn, error, reset } = useAdminSignIn() - useEffect(() => { - if (adminAddress && !connectedAddress && isDisconnected) { - contestsAPI.get(adminSignOutUrl()) - } - }, [connectedAddress, adminAddress, isDisconnected]) - const handleSignInAsAdmin = useCallback(() => { signIn() }, [signIn]) @@ -33,10 +25,7 @@ const AppInternal = () => { reset() }, [reset]) - const validAdmin = useMemo( - () => adminAddress && connectedAddress && adminAddress === connectedAddress, - [adminAddress, connectedAddress] - ) + const validAdmin = useMemo(() => !!adminAddress, [adminAddress]) const navigationLinks: NavigationLink[] = validAdmin ? [ @@ -48,17 +37,13 @@ const AppInternal = () => { title: "CONTESTS", route: adminRoutes.Contests, }, - { - title: "SCOPE", - route: adminRoutes.Scope, - }, ] : [] return (
-
+
{validAdmin ? ( diff --git a/src/AppProtocolDashboard.tsx b/src/AppProtocolDashboard.tsx index b47c4b48..6f735a30 100644 --- a/src/AppProtocolDashboard.tsx +++ b/src/AppProtocolDashboard.tsx @@ -96,7 +96,11 @@ const AppProtocolDashboard = () => { Contest Prize Pool - {`${commify(protocolDashboard.contest.prizePool)} USDC`} + + {protocolDashboard.contest.prizePool + ? ` ${commify(protocolDashboard.contest.prizePool)} USDC` + : "TBD"} + @@ -104,9 +108,11 @@ const AppProtocolDashboard = () => { Lead Senior Watson Fixed Pay - {`${commify( - protocolDashboard.contest.leadSeniorAuditorFixedPay - )} USDC`} + + {protocolDashboard.contest.leadSeniorAuditorFixedPay + ? `${commify(protocolDashboard.contest.leadSeniorAuditorFixedPay)} USDC` + : "TBD"} + diff --git a/src/components/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss new file mode 100644 index 00000000..9c186b2d --- /dev/null +++ b/src/components/Checkbox/Checkbox.module.scss @@ -0,0 +1,25 @@ +@import "../../styles/variables"; + +.container { + height: 2rem; + width: 2rem; + border-radius: 4px; + border: 1px solid $primary-purple; + color: $primary-purple; + cursor: pointer; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); + + &:hover { + border-color: lighten($primary-purple, 20%); + color: lighten($primary-purple, 20%); + } + + &:active { + border-color: darken($primary-purple, 20%); + color: darken($primary-purple, 20%); + } +} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 00000000..61c78666 --- /dev/null +++ b/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,17 @@ +import { FaCheck } from "react-icons/fa" +import styles from "./Checkbox.module.scss" + +type Props = { + checked?: boolean + onChange?: (checked: boolean) => void +} + +const Checkbox: React.FC = ({ checked = false, onChange }) => { + return ( +
onChange?.(!checked)}> + {checked && } +
+ ) +} + +export default Checkbox diff --git a/src/components/ClaimsList/ClaimsList.tsx b/src/components/ClaimsList/ClaimsList.tsx index 9d3312ae..7e89f387 100644 --- a/src/components/ClaimsList/ClaimsList.tsx +++ b/src/components/ClaimsList/ClaimsList.tsx @@ -7,7 +7,18 @@ import { formatAmount } from "../../utils/format" import styles from "./ClaimsList.module.scss" import cx from "classnames" -const CLAIMS = [ +type Claim = { + id: number + protocol: string + date: string + coverageAgreementUrl: string + evidenceUrl?: string + amount: number + txHashUrl: string + status: "paid" | "denied" +} + +const CLAIMS: Claim[] = [ { id: 1, protocol: "Euler", @@ -18,6 +29,7 @@ const CLAIMS = [ "https://sherlock-files.ams3.digitaloceanspaces.com/claims/Euler_0x3019e52a670390f24e4b9b58af62a7367658e457bbb07f86b19b213ec74b5be7_16817996.pdf?hash=d6c8e864a5312384143a21f542e998f6e83357f5b8e3e34ccdcb551404d25c09", amount: 4_529_285, txHashUrl: "https://etherscan.io/tx/0x234cd8e369fdcd0387ada4214e563a6a72aad4abd0b464a2873f3eb9dac2579b", + status: "paid", }, { id: 2, @@ -29,9 +41,29 @@ const CLAIMS = [ "https://sherlock-files.ams3.digitaloceanspaces.com/claims/Sentiment_0x5af5b22283e35ef9d9d4a32753014cdc40fd7a5a5d920d83d2c1e901c10a0a7c_16977102.pdf?hash=f513422f0d98aaac6f669142caf9148172ca7f2a17559f3c1cb26177df11e8ef", amount: 65_701, txHashUrl: "https://etherscan.io/tx/0xe67bbb7a9085f1a603fcea1eefe08c7674a2f504e81f6d69df41c8b077c2765e", + status: "paid", + }, + { + id: 3, + protocol: "Ajna", + date: "Sept 26, 2023", + coverageAgreementUrl: + "https://github.com/sherlock-protocol/sherlock-reports/blob/main/coverage-agreements/Ajna%20Coverage%20Agreement%202023.07.15.pdf", + amount: 49_500, + txHashUrl: "https://etherscan.io/tx/0xa4a8fbea2c24662ea46e25ab3ab69456a8327463803c4851948c2ba2234fed5f", + status: "paid", }, ] +function getStatusLabel(status: "paid" | "denied") { + switch (status) { + case "paid": + return "Paid" + case "denied": + return "Denied" + } +} + /** * List of past claims */ @@ -64,7 +96,7 @@ const ClaimsList: React.FC = () => { Evidence - Transaction + Status @@ -89,16 +121,20 @@ const ClaimsList: React.FC = () => { - - - Link - - + {item.evidenceUrl ? ( + + + Link + + + ) : ( + - + )} - Link + {getStatusLabel(item.status)} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index c685c4e9..55e3442b 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -16,7 +16,7 @@ import { Title } from "../Title" export type NavigationLink = { title: string - route: Route + route: Route | string external?: boolean protected?: boolean } @@ -48,6 +48,8 @@ type HeaderProps = { * URL of logo. Default to Sherlock's */ logoURL?: string + + includePayouts?: boolean } /** @@ -60,6 +62,7 @@ export const Header: React.FC = ({ connectButton = true, title, logoURL, + includePayouts, }) => { const { authenticate } = useAuthentication() const { data: authenticatedProfile, isFetched } = useProfile() @@ -104,6 +107,7 @@ export const Header: React.FC = ({ {navLink.external && } ))} + {includePayouts ? PAYOUTS : null}
)} diff --git a/src/components/RadioButton/RadioButton.module.scss b/src/components/RadioButton/RadioButton.module.scss new file mode 100644 index 00000000..a69d3802 --- /dev/null +++ b/src/components/RadioButton/RadioButton.module.scss @@ -0,0 +1,35 @@ +@import "../../styles/variables"; + +.options { + border: 3px solid #000; + height: 3.315rem; + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: center; + + .option { + height: 100%; + width: 100%; + background: darken($primary-purple, 30%); + padding: 0 2rem; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid black; + cursor: pointer; + + &.active { + background: $primary-purple; + font-weight: bold; + } + + &:hover { + background: darken($primary-purple, 10%); + } + + &:active { + background: darken($primary-purple, 35%); + } + } +} diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx new file mode 100644 index 00000000..6c6f70a0 --- /dev/null +++ b/src/components/RadioButton/RadioButton.tsx @@ -0,0 +1,39 @@ +import { Column, Row } from "../Layout" +import { Text } from "../Text" +import styles from "./RadioButton.module.scss" +import cx from "classnames" + +type Props = { + value: any + label?: string + options: Array<{ + label: string + value: any + }> + onChange: (value: any) => void +} + +const RadioButton: React.FC = ({ options, value, label, onChange }) => { + return ( + + {label && ( + + {label} + + )} +
+ {options.map((option) => ( +
onChange?.(option.value)} + > + {option.label} +
+ ))} +
+
+ ) +} + +export default RadioButton diff --git a/src/components/Select/Select.module.scss b/src/components/Select/Select.module.scss index f06f8a3b..e27ebdda 100644 --- a/src/components/Select/Select.module.scss +++ b/src/components/Select/Select.module.scss @@ -7,7 +7,6 @@ border: 3px solid black; background-color: $primary-purple; - width: 180px; padding: $spacing-m; align-items: center; @@ -20,6 +19,18 @@ &:active { background-color: darken($color: $primary-purple, $amount: 15%); } + + &.small { + width: 180px; + } + + &.full-width { + flex-grow: 1; + } + } + + &.full-width { + flex-grow: 1; } .optionsContainer { diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 0136b17b..46d8cce2 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -4,23 +4,25 @@ import { Column, Row } from "../Layout" import { Text } from "../Text" import { FaCaretDown } from "react-icons/fa" import Option from "./Option" +import cx from "classnames" type OptionType = { - value: T + value?: T label: string } type Props = { options: Array> - onChange: (value: T) => void + onChange: (value?: T) => void value?: T placeholder?: string + variant?: "small" | "full-width" } /** * Custom Select/Dropdown component */ -const Select = ({ options, onChange, value, placeholder }: Props) => { +const Select = ({ options, onChange, value, placeholder, variant = "small" }: Props) => { // const [selectedOption, setSelectedOption] = React.useState() const selectedOptionLabel = React.useMemo( () => options?.find((item) => item.value === value)?.label ?? placeholder, @@ -36,7 +38,7 @@ const Select = ({ options, onChange, value, placeholder }: Props) => { const [optionsVisible, setOptionsVisible] = React.useState(false) const handleUpdateSelectedOption = React.useCallback( - (option: T) => { + (option: T | undefined) => { setOptionsVisible(false) onChange?.(option) }, @@ -61,8 +63,13 @@ const Select = ({ options, onChange, value, placeholder }: Props) => { }, [options, value, handleUpdateSelectedOption, placeholder]) return ( - - + + {hasOptions ? selectedOptionLabel : "No entries"} diff --git a/src/components/StakingPositionsList/StakingPositionsList.tsx b/src/components/StakingPositionsList/StakingPositionsList.tsx index 3b220fef..3a5c2440 100644 --- a/src/components/StakingPositionsList/StakingPositionsList.tsx +++ b/src/components/StakingPositionsList/StakingPositionsList.tsx @@ -136,30 +136,19 @@ export const StakingPositionsList: React.FC = () => { navigate("/") }, [navigate]) - const mapleAlertVisible = useMemo(() => positions?.some((item) => item.id <= 442), [positions]) - if (!data) return null return ( - {mapleAlertVisible && ( - - - Stakers affected by the Maple loss - - A portion of the Maple funds will be airdropped directly to staker addresses instead of delivered when - unstaking. More info{" "} - - here - - . - - - - )} + + + Important Information for Unstaking + + If a position is not unstaked within two weeks of unlocking, the position will get automatically restaked + for 6 months + + +
{positions.map((position) => ( , "value" | "onChange"> & { token: InputToken onChange: (value?: BigNumber) => void initialValue?: BigNumber + displayTokenLabel?: boolean } export const decimalsByToken: Record = { @@ -27,7 +28,14 @@ export const decimalsByToken: Record = { const decommify = (value: string) => value.replaceAll(",", "") -export const TokenInput: React.FC = ({ balance, token, onChange, initialValue, ...props }) => { +export const TokenInput: React.FC = ({ + balance, + token, + onChange, + initialValue, + displayTokenLabel = true, + ...props +}) => { const [amount, amountBN, setAmount, setAmountBN] = useAmountState(decimalsByToken[token]) const { disabled } = props @@ -62,11 +70,13 @@ export const TokenInput: React.FC = ({ balance, token, onChange, initialV - - - {token} - - + {displayTokenLabel && ( + + + {token} + + + )} {balance && !disabled && ( diff --git a/src/components/Tooltip/Tooltip.module.scss b/src/components/Tooltip/Tooltip.module.scss new file mode 100644 index 00000000..0a64e846 --- /dev/null +++ b/src/components/Tooltip/Tooltip.module.scss @@ -0,0 +1,17 @@ +@import "../../styles/variables"; + +.container { + display: inline-block; + + &:hover { + cursor: pointer; + } +} + +.tooltip { + position: fixed; + padding: 10px; + + background: $background-color; + border: 1px solid $primary-purple; +} diff --git a/src/components/Tooltip/Tooltip.tsx b/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 00000000..a6d851b7 --- /dev/null +++ b/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,168 @@ +import * as React from "react" +import { + useFloating, + autoUpdate, + offset, + flip, + shift, + useHover, + useFocus, + useDismiss, + useRole, + useInteractions, + useMergeRefs, + FloatingPortal, + safePolygon, +} from "@floating-ui/react" +import type { Placement } from "@floating-ui/react" +import cx from "classnames" + +interface TooltipOptions { + initialOpen?: boolean + placement?: Placement + open?: boolean + onOpenChange?: (open: boolean) => void +} + +export function useTooltip({ + initialOpen = false, + placement = "top", + open: controlledOpen, + onOpenChange: setControlledOpen, +}: TooltipOptions = {}) { + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(initialOpen) + + const open = controlledOpen ?? uncontrolledOpen + const setOpen = setControlledOpen ?? setUncontrolledOpen + + const data = useFloating({ + placement, + open, + onOpenChange: setOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(5), + flip({ + crossAxis: placement.includes("-"), + fallbackAxisSideDirection: "start", + padding: 5, + }), + shift({ padding: 5 }), + ], + }) + + const context = data.context + + const hover = useHover(context, { + move: false, + enabled: controlledOpen == null, + handleClose: safePolygon({ requireIntent: false }), + }) + const focus = useFocus(context, { + enabled: controlledOpen == null, + }) + const dismiss = useDismiss(context) + const role = useRole(context, { role: "tooltip" }) + + const interactions = useInteractions([hover, focus, dismiss, role]) + + return React.useMemo( + () => ({ + open, + setOpen, + ...interactions, + ...data, + }), + [open, setOpen, interactions, data] + ) +} + +type ContextType = ReturnType | null + +const TooltipContext = React.createContext(null) + +export const useTooltipContext = () => { + const context = React.useContext(TooltipContext) + + if (context == null) { + throw new Error("Tooltip components must be wrapped in ") + } + + return context +} + +export function Tooltip({ children, ...options }: { children: React.ReactNode } & TooltipOptions) { + // This can accept any props as options, e.g. `placement`, + // or other positioning options. + const tooltip = useTooltip(options) + return {children} +} + +export const TooltipTrigger = React.forwardRef & { asChild?: boolean }>( + function TooltipTrigger({ children, asChild = false, ...props }, propRef) { + const context = useTooltipContext() + const childrenRef = (children as any).ref + const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]) + + // `asChild` allows the user to pass any element as the anchor + if (asChild && React.isValidElement(children)) { + return React.cloneElement( + children, + context.getReferenceProps({ + ref, + ...props, + ...children.props, + "data-state": context.open ? "open" : "closed", + }) + ) + } + + return ( +
+ {children} +
+ ) + } +) + +type TooltipContentProps = { + padding?: boolean +} & React.HTMLProps + +export const TooltipContent = React.forwardRef(function TooltipContent( + { style, padding = true, ...props }, + propRef +) { + const context = useTooltipContext() + const ref = useMergeRefs([context.refs.setFloating, propRef]) + + if (!context.open) return null + + return ( + +
+ + ) +}) diff --git a/src/hooks/api/admin/useAdminConfirmContest.ts b/src/hooks/api/admin/useAdminConfirmContest.ts new file mode 100644 index 00000000..6a70f226 --- /dev/null +++ b/src/hooks/api/admin/useAdminConfirmContest.ts @@ -0,0 +1,74 @@ +import { DateTime } from "luxon" +import { useMutation, useQueryClient } from "react-query" +import { contests as contestsAPI } from "../axios" +import { adminConfirmContest as adminConfirmContestUrl } from "../urls" +import { AxiosError } from "axios" +import { adminContestsQuery } from "./useAdminContests" + +type AdminConfirmContestParams = { + id: number + title: string + shortDescription: string + startDate: DateTime + endDate: DateTime + auditRewards: number + judgingPrizePool: number + leadJudgeFixedPay: number + fullPayment: number + lswPaymentStructure?: "TIERED" | "BEST_EFFORTS" | "FIXED" + customLswFixedPay?: number | null + private?: boolean + requiresKYC?: boolean + maxNumberOfParticipants?: number | null + token: string + exchangeRate: number +} + +export const useAdminConfirmContest = () => { + const queryClient = useQueryClient() + const { mutate, mutateAsync, ...mutation } = useMutation( + async (params) => { + try { + await contestsAPI.post( + adminConfirmContestUrl(params.id), + { + title: params.title, + short_description: params.shortDescription, + starts_at: params.startDate.toSeconds(), + ends_at: params.endDate.toSeconds(), + audit_rewards: params.auditRewards, + judging_prize_pool: params.judgingPrizePool, + lead_judge_fixed_pay: params.leadJudgeFixedPay, + full_payment: params.fullPayment, + lsw_payment_structure: params.lswPaymentStructure, + custom_lsw_fixed_pay: params.customLswFixedPay, + private: params.private, + requires_kyc: params.requiresKYC, + max_number_of_participants: params.maxNumberOfParticipants, + token: params.token, + exchange_rate: params.exchangeRate, + }, + + { + timeout: 5 * 60 * 1000, + } + ) + } catch (error) { + const axiosError = error as AxiosError + throw Error(axiosError.response?.data.error ?? "Something went wrong. Please, try again.") + } + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries(adminContestsQuery("draft")) + await queryClient.invalidateQueries(adminContestsQuery("active")) + await queryClient.invalidateQueries(adminContestsQuery("finished")) + }, + } + ) + + return { + confirmContest: mutate, + ...mutation, + } +} diff --git a/src/hooks/api/admin/useAdminContest.ts b/src/hooks/api/admin/useAdminContest.ts new file mode 100644 index 00000000..23aef9e4 --- /dev/null +++ b/src/hooks/api/admin/useAdminContest.ts @@ -0,0 +1,31 @@ +import { useQuery } from "react-query" +import { contests as contestsAPI } from "../axios" +import { getAdminContest as getAdminContestUrl } from "../urls" +import { ContestsListItem, GetAdminContestsResponse, parseContest } from "./useAdminContests" + +type AdminContestResponse = { + contest: GetAdminContestsResponse & { + nsloc?: number + expected_nsloc?: number + context_questions_ready?: boolean + } +} + +type AdminContest = ContestsListItem & { + nSLOC?: number + expectedNSLOC?: number + contextQuestionsReady?: boolean +} + +export const adminContestQuery = (contestID: number) => ["admin-contest", contestID] +export const useAdminContest = (contestID: number) => + useQuery(adminContestQuery(contestID), async () => { + const { data } = await contestsAPI.get(getAdminContestUrl(contestID)) + + return { + ...parseContest(data.contest), + nSLOC: data.contest.nsloc, + expectedNSLOC: data.contest.expected_nsloc, + contextQuestionsReady: data.contest.context_questions_ready, + } + }) diff --git a/src/hooks/api/admin/useAdminContestScope.ts b/src/hooks/api/admin/useAdminContestScope.ts index 5762b50c..730abb2f 100644 --- a/src/hooks/api/admin/useAdminContestScope.ts +++ b/src/hooks/api/admin/useAdminContestScope.ts @@ -8,7 +8,11 @@ type AdminContestScopeResponse = { repo_name: string branch_name: string commit_hash: string - files: string[] + files: { + file_path: string + nsloc?: number + selected: boolean + }[] solidity_metrics_report: string nsloc: number comment_to_source_ratio: number @@ -17,14 +21,18 @@ type AdminContestScopeResponse = { export const adminContestScopeQuery = (contestID: number) => ["admin-contest-scope", contestID] export const useAdminContestScope = (contestID: number) => - useQuery(adminContestScopeQuery(contestID), async () => { + useQuery(adminContestScopeQuery(contestID), async () => { const { data } = await contestsAPI.get(getAdminContestScopeUrl(contestID)) return data.scope.map((d) => ({ repoName: d.repo_name, branchName: d.branch_name, commitHash: d.commit_hash, - files: d.files, + files: d.files.map((f) => ({ + filePath: f.file_path, + nSLOC: f.nsloc, + selected: f.selected, + })), solidityMetricsReport: d.solidity_metrics_report, nSLOC: d.nsloc, commentToSourceRatio: d.comment_to_source_ratio, diff --git a/src/hooks/api/admin/useAdminContestTweetPreview.ts b/src/hooks/api/admin/useAdminContestTweetPreview.ts index 4020155f..f3e3858d 100644 --- a/src/hooks/api/admin/useAdminContestTweetPreview.ts +++ b/src/hooks/api/admin/useAdminContestTweetPreview.ts @@ -4,8 +4,8 @@ import { getAdminContestTweetPreview as getAdminContestTweetPreviewUrl } from ". export const adminContestTweetPreviewKey = (contestID: number) => ["contest-tweet-preview", contestID] export const useAdminContestTweetPreview = (contestID: number) => - useQuery(adminContestTweetPreviewKey(contestID), async () => { - const { data } = await contestsAPI.get(getAdminContestTweetPreviewUrl(contestID)) + useQuery(adminContestTweetPreviewKey(contestID), async () => { + const { data } = await contestsAPI.get<{ tweets: string[] }>(getAdminContestTweetPreviewUrl(contestID)) - return data + return data.tweets }) diff --git a/src/hooks/api/admin/useAdminContestVariables.ts b/src/hooks/api/admin/useAdminContestVariables.ts index 4525899d..f04a196b 100644 --- a/src/hooks/api/admin/useAdminContestVariables.ts +++ b/src/hooks/api/admin/useAdminContestVariables.ts @@ -2,21 +2,29 @@ import { useQuery } from "react-query" import { contests as contestsAPI } from "../axios" type GetAdminContestVariablesResponse = { - length: number - full_payment: number - contest_rewards: number - judging_prize_pool: number + min_total_rewards: number + min_contest_rewards: number + min_total_price: number + rec_total_rewards: number + rec_contest_rewards: number + rec_total_price: number + spreadsheet_row: number lead_judge_fixed_pay: number - admin_fee: number + judging_prize_pool: number + length: number } type ContestVariables = { - length: number - fullPayment: number - auditContestRewards: number - judgingPrizePool: number + minTotalRewards: number + minContestRewards: number + minTotalPrice: number + recTotalRewards: number + recContestRewards: number + recTotalPrice: number + spreadsheetRow: number leadJudgeFixedPay: number - adminFee: number + judgingPrizePool: number + length: number } export const adminContestVariablesQueryKey = (nSLOC: number) => ["contest-variables", nSLOC] @@ -25,11 +33,15 @@ export const useAdminContestVariables = (nSLOC: number) => const { data } = await contestsAPI.get(`/admin/contest/variables?nsloc=${nSLOC}`) return { - length: data.length, - fullPayment: data.full_payment, - auditContestRewards: data.contest_rewards, - judgingPrizePool: data.judging_prize_pool, + minTotalRewards: data.min_total_rewards, + minContestRewards: data.min_contest_rewards, + minTotalPrice: data.min_total_price, + recTotalRewards: data.rec_total_rewards, + recContestRewards: data.rec_contest_rewards, + recTotalPrice: data.rec_total_price, + spreadsheetRow: data.spreadsheet_row, leadJudgeFixedPay: data.lead_judge_fixed_pay, - adminFee: data.admin_fee, + judgingPrizePool: data.judging_prize_pool, + length: data.length, } }) diff --git a/src/hooks/api/admin/useAdminContests.ts b/src/hooks/api/admin/useAdminContests.ts index 8ee32661..909de0c6 100644 --- a/src/hooks/api/admin/useAdminContests.ts +++ b/src/hooks/api/admin/useAdminContests.ts @@ -3,15 +3,16 @@ import { contests as contestsAPI } from "../axios" import { getAdminContests as getAdminContestsUrl } from "../urls" -export type ContestStatus = "CREATED" | "RUNNING" | "JUDGING" | "FINISHED" | "ESCALATING" | "SHERLOCK_JUDGING" +export type ContestStatus = "DRAFT" | "CREATED" | "RUNNING" | "JUDGING" | "FINISHED" | "ESCALATING" | "SHERLOCK_JUDGING" export type ContestsListItem = { id: number title: string + shortDescription: string logoURL: string status: ContestStatus initialPayment: boolean - fullPayment: boolean + fullPaymentComplete: boolean adminUpcomingApproved: boolean adminStartApproved: boolean dashboardID?: string @@ -20,14 +21,34 @@ export type ContestsListItem = { submissionReady: boolean hasSolidityMetricsReport: boolean leadSeniorAuditorHandle: string + leadSeniorAuditorFixedPay: number | null leadSeniorSelectionMessageSentAt: number + leadSeniorSelectionDate: number leadSeniorConfirmationMessage: string auditReport?: string + nSLOC?: number + expectedNSLOC?: number + rewards: number + judgingPrizePool: number + leadJudgeFixedPay: number + fullPayment: number + initialScopeSubmitted: boolean + initialScopeSubmittedAt: number | null + finalScopeSubmitted: boolean + telegramChat?: string + finalReportAvailable?: boolean + lswPaymentStructure: "TIERED" | "BEST_EFFORTS" | "FIXED" + customLswFixedPay: number | null + private: boolean + requiresKYC: boolean + maxNumberOfParticipants: number | null + token: string } -type GetAdminContestsResponse = { +export type GetAdminContestsResponse = { id: number title: string + short_description: string logo_url: string status: ContestStatus initial_payment_complete: boolean @@ -41,34 +62,75 @@ type GetAdminContestsResponse = { has_solidity_metrics_report: boolean lead_senior_auditor_handle: string senior_selection_message_sent_at: number + senior_selection_date: number senior_confirmed_message: string audit_report?: string -}[] + nsloc?: number + expected_nsloc?: number + audit_rewards: number + judging_prize_pool: number + lead_judge_fixed_pay: number + full_payment: number + initial_scope_submitted: boolean + initial_scope_submitted_at: number | null + final_scope_submitted: boolean + telegram_chat?: string + final_report_available?: boolean + lsw_payment_structure: "TIERED" | "BEST_EFFORTS" | "FIXED" + lead_senior_auditor_fixed_pay: number | null + private: boolean + requires_kyc: boolean + max_number_of_participants: number | null + token: string +} + +export type ContestListStatus = "active" | "finished" | "draft" -export type ContestListStatus = "active" | "finished" +export const parseContest = (d: GetAdminContestsResponse): ContestsListItem => { + return { + id: d.id, + title: d.title, + shortDescription: d.short_description, + logoURL: d.logo_url, + status: d.status, + initialPayment: d.initial_payment_complete, + fullPaymentComplete: d.full_payment_complete, + adminUpcomingApproved: d.admin_upcoming_approved, + adminStartApproved: d.admin_start_approved, + dashboardID: d.dashboard_id, + startDate: d.starts_at, + endDate: d.ends_at, + submissionReady: d.protocol_submission_ready, + hasSolidityMetricsReport: d.has_solidity_metrics_report, + leadSeniorAuditorHandle: d.lead_senior_auditor_handle, + leadSeniorSelectionMessageSentAt: d.senior_selection_message_sent_at, + leadSeniorSelectionDate: d.senior_selection_date, + leadSeniorConfirmationMessage: d.senior_confirmed_message, + auditReport: d.audit_report, + rewards: d.audit_rewards, + judgingPrizePool: d.judging_prize_pool, + leadJudgeFixedPay: d.lead_judge_fixed_pay, + fullPayment: d.full_payment, + initialScopeSubmitted: d.initial_scope_submitted, + initialScopeSubmittedAt: d.initial_scope_submitted_at, + finalScopeSubmitted: d.final_scope_submitted, + nSLOC: d.nsloc, + telegramChat: d.telegram_chat, + finalReportAvailable: d.final_report_available, + lswPaymentStructure: d.lsw_payment_structure, + customLswFixedPay: d.lead_senior_auditor_fixed_pay, + private: d.private, + requiresKYC: d.requires_kyc, + maxNumberOfParticipants: d.max_number_of_participants, + leadSeniorAuditorFixedPay: d.lead_senior_auditor_fixed_pay, + token: d.token, + } +} export const adminContestsQuery = (status: ContestListStatus) => ["admin-contests", status] export const useAdminContests = (status: ContestListStatus) => useQuery(adminContestsQuery(status), async () => { - const { data } = await contestsAPI.get(getAdminContestsUrl(status)) + const { data } = await contestsAPI.get(getAdminContestsUrl(status)) - return data.map((d) => ({ - id: d.id, - title: d.title, - logoURL: d.logo_url, - status: d.status, - initialPayment: d.initial_payment_complete, - fullPayment: d.full_payment_complete, - adminUpcomingApproved: d.admin_upcoming_approved, - adminStartApproved: d.admin_start_approved, - dashboardID: d.dashboard_id, - startDate: d.starts_at, - endDate: d.ends_at, - submissionReady: d.protocol_submission_ready, - hasSolidityMetricsReport: d.has_solidity_metrics_report, - leadSeniorAuditorHandle: d.lead_senior_auditor_handle, - leadSeniorSelectionMessageSentAt: d.senior_selection_message_sent_at, - leadSeniorConfirmationMessage: d.senior_confirmed_message, - auditReport: d.audit_report, - })) + return data.map(parseContest) }) diff --git a/src/hooks/api/admin/useAdminCreateContest.ts b/src/hooks/api/admin/useAdminCreateContest.ts index 9dcb31da..9bd44824 100644 --- a/src/hooks/api/admin/useAdminCreateContest.ts +++ b/src/hooks/api/admin/useAdminCreateContest.ts @@ -16,14 +16,7 @@ type AdminCreateContestParams = { } contest: { title: string - shortDescription: string - nSLOC: string - startDate: DateTime - endDate: DateTime - auditRewards: number - judgingPrizePool: number - leadJudgeFixedPay: number - fullPayment: number + previousContestId?: number | null } } @@ -42,14 +35,7 @@ export const useAdminCreateContest = () => { website: params.protocol.website, }, title: params.contest.title, - short_description: params.contest.shortDescription, - lines_of_code: params.contest.nSLOC, - starts_at: params.contest.startDate.toSeconds(), - ends_at: params.contest.endDate.toSeconds(), - audit_rewards: params.contest.auditRewards, - judging_prize_pool: params.contest.judgingPrizePool, - lead_judge_fixed_pay: params.contest.leadJudgeFixedPay, - full_payment: params.contest.fullPayment, + previous_contest_id: params.contest.previousContestId, }) } catch (error) { const axiosError = error as AxiosError @@ -58,6 +44,7 @@ export const useAdminCreateContest = () => { }, { onSuccess: async () => { + await queryClient.invalidateQueries(adminContestsQuery("draft")) await queryClient.invalidateQueries(adminContestsQuery("active")) }, } diff --git a/src/hooks/api/admin/useAdminDeleteDraftContest.ts b/src/hooks/api/admin/useAdminDeleteDraftContest.ts new file mode 100644 index 00000000..ec518a1f --- /dev/null +++ b/src/hooks/api/admin/useAdminDeleteDraftContest.ts @@ -0,0 +1,29 @@ +import { useMutation } from "wagmi" +import { adminDeleteDraftContest as adminDeleteDraftContestUrl } from "../urls" +import { contests as contestsAPI } from "../axios" +import { useQueryClient } from "react-query" +import { adminContestsQuery } from "./useAdminContests" + +type AdminDeleteDraftContestParams = { + contestID: number +} + +export const useAdminDeleteDraftContest = () => { + const queryClient = useQueryClient() + + const { mutate, mutateAsync, ...mutation } = useMutation( + async (params) => { + await contestsAPI.delete(adminDeleteDraftContestUrl(params.contestID)) + }, + { + async onSettled(data, error, params) { + await queryClient.invalidateQueries(adminContestsQuery("draft")) + }, + } + ) + + return { + deleteContest: mutate, + ...mutation, + } +} diff --git a/src/hooks/api/admin/useAdminPricing.ts b/src/hooks/api/admin/useAdminPricing.ts new file mode 100644 index 00000000..f3a12bf8 --- /dev/null +++ b/src/hooks/api/admin/useAdminPricing.ts @@ -0,0 +1,69 @@ +import { useQuery } from "react-query" +import { contests as contestsAPI } from "../axios" + +type PricingType = "MINIMUM" | "RECOMMENDED" + +type GetAdminPricingResponse = { + audit_contest_rewards: number + bottom_3rd_contest_pot: number + bottom_3rd_lsw_pay: number + judging_prize_pool: number + lead_judge_fixed_pay: number + length: number + middle_3rd_contest_pot: number + middle_3rd_lsw_pay: number + referral_fee: number + reserved_auditor_fixed_pay: number | null + sherlock_fee: number + top_3rd_contest_pot: number + top_3rd_lsw_pay: number + total_price: number + total_rewards: number + type: PricingType + exchangeRate: number +}[] + +type Pricing = { + minTotalRewards: number + minContestRewards: number + minTotalPrice: number + minLeadJudgeFixedPay: number + minJudgingPrizePool: number + recTotalRewards: number + recContestRewards: number + recTotalPrice: number + recLeadJudgeFixedPay: number + recJudgingPrizePool: number + length: number + exchangeRate: number +} + +export const adminPricingQueryKey = (nSLOC: number, token: string) => ["contest-variables", nSLOC, token] +export const useAdminPricing = (nSLOC: number, token: string) => + useQuery(adminPricingQueryKey(nSLOC, token), async (): Promise => { + const { data } = await contestsAPI.get( + `/internal/simulate-pricing?nsloc=${nSLOC}&token=${token}` + ) + + const minimumPricing = data.find((p) => p.type === "MINIMUM") + const recommendedPricing = data.find((p) => p.type === "RECOMMENDED") + + if (!minimumPricing || !recommendedPricing) { + throw Error("Missing pricing data") + } + + return { + minTotalRewards: minimumPricing.total_rewards, + minContestRewards: minimumPricing.audit_contest_rewards, + minTotalPrice: minimumPricing.total_price, + minLeadJudgeFixedPay: minimumPricing.lead_judge_fixed_pay, + minJudgingPrizePool: minimumPricing.judging_prize_pool, + recTotalRewards: recommendedPricing.total_rewards, + recContestRewards: recommendedPricing.audit_contest_rewards, + recTotalPrice: recommendedPricing.total_price, + recLeadJudgeFixedPay: recommendedPricing.lead_judge_fixed_pay, + recJudgingPrizePool: recommendedPricing?.judging_prize_pool, + length: minimumPricing.length, + exchangeRate: minimumPricing.exchangeRate, + } + }) diff --git a/src/hooks/api/admin/useAdminProtocolContests.ts b/src/hooks/api/admin/useAdminProtocolContests.ts new file mode 100644 index 00000000..67c2c77d --- /dev/null +++ b/src/hooks/api/admin/useAdminProtocolContests.ts @@ -0,0 +1,22 @@ +import { useQuery } from "react-query" +import { contests as contestsAPI } from "../axios" +import { GetAdminContestsResponse, parseContest } from "./useAdminContests" +import { getAdminProtocolContests } from "../urls" + +export const adminProtocolContestsKey = (protocolID: number) => ["protocol-last-contest", protocolID] +export const useAdminProtocolContests = (protocolID: number | undefined) => + useQuery( + adminProtocolContestsKey(protocolID ?? -1), + async () => { + const { data } = await contestsAPI.get(getAdminProtocolContests(protocolID ?? -1)) + + if (!data) { + return null + } + + return data?.map(parseContest) + }, + { + enabled: !!protocolID, + } + ) diff --git a/src/hooks/api/admin/useAdminResetQA.ts b/src/hooks/api/admin/useAdminResetQA.ts new file mode 100644 index 00000000..6bb9231b --- /dev/null +++ b/src/hooks/api/admin/useAdminResetQA.ts @@ -0,0 +1,30 @@ +import { useMutation } from "wagmi" +import { adminResetQA as adminResetQAUrl } from "../urls" +import { contests as contestsAPI } from "../axios" +import { useQueryClient } from "react-query" +import { adminContestsQuery } from "./useAdminContests" +import { adminContestQuery } from "./useAdminContest" + +type AdminResetQAParams = { + contestID: number +} + +export const useAdminResetQA = () => { + const queryClient = useQueryClient() + + const { mutate, mutateAsync, ...mutation } = useMutation( + async (params) => { + await contestsAPI.post(adminResetQAUrl(params.contestID)) + }, + { + async onSettled(data, error, params) { + await queryClient.invalidateQueries(adminContestQuery(params.contestID)) + }, + } + ) + + return { + resetQA: mutate, + ...mutation, + } +} diff --git a/src/hooks/api/admin/useAdminResetScope.ts b/src/hooks/api/admin/useAdminResetScope.ts new file mode 100644 index 00000000..2540d5cb --- /dev/null +++ b/src/hooks/api/admin/useAdminResetScope.ts @@ -0,0 +1,30 @@ +import { useMutation } from "wagmi" +import { adminResetScope as adminResetScopeUrl } from "../urls" +import { contests as contestsAPI } from "../axios" +import { useQueryClient } from "react-query" +import { adminContestsQuery } from "./useAdminContests" + +type AdminResetScopeParams = { + contestID: number + scopeType: "initial" | "final" +} + +export const useAdminResetScope = () => { + const queryClient = useQueryClient() + + const { mutate, mutateAsync, ...mutation } = useMutation( + async (params) => { + await contestsAPI.delete(adminResetScopeUrl(params.contestID, params.scopeType)) + }, + { + async onSettled(data, error, params) { + await queryClient.invalidateQueries(adminContestsQuery(params.scopeType === "initial" ? "draft" : "active")) + }, + } + ) + + return { + resetScope: mutate, + ...mutation, + } +} diff --git a/src/hooks/api/admin/useAdminSubmitScope.ts b/src/hooks/api/admin/useAdminSubmitScope.ts index 588c17d4..e7d7aa5b 100644 --- a/src/hooks/api/admin/useAdminSubmitScope.ts +++ b/src/hooks/api/admin/useAdminSubmitScope.ts @@ -9,19 +9,14 @@ type AdminSubmitScopeParams = { branchName: string commitHash: string files: string[] + nSLOCAdjustment?: number } type AdminSubmitScopeResponse = { - report: { - url: string - nSLOC: number - } + report: string } -type Report = { - url: string - nSLOC: number -} +type Report = string export const useAdminSubmitScope = () => { const { @@ -38,16 +33,14 @@ export const useAdminSubmitScope = () => { branch_name: params.branchName, commit_hash: params.commitHash, files: params.files, + nsloc_adjustment: params.nSLOCAdjustment, }, { timeout: 5 * 60 * 1000, } ) - return { - url: data.report.url, - nSLOC: data.report.nSLOC, - } + return data.report } catch (error) { const axiosError = error as AxiosError throw Error(axiosError.response?.data.error ?? "Something went wrong. Please, try again.") diff --git a/src/hooks/api/admin/useAdminUpdateContest.ts b/src/hooks/api/admin/useAdminUpdateContest.ts new file mode 100644 index 00000000..699a66e0 --- /dev/null +++ b/src/hooks/api/admin/useAdminUpdateContest.ts @@ -0,0 +1,66 @@ +import { DateTime } from "luxon" +import { useMutation, useQueryClient } from "react-query" +import { contests as contestsAPI } from "../axios" +import { adminUpdateContest as adminUpdateContestUrl } from "../urls" +import { AxiosError } from "axios" +import { adminContestsQuery } from "./useAdminContests" + +type AdminUpdateContestParams = { + id: number + title: string + shortDescription: string + startDate: DateTime + endDate: DateTime + auditRewards: number + judgingPrizePool: number + leadJudgeFixedPay: number + fullPayment: number + lswPaymentStructure?: "TIERED" | "BEST_EFFORTS" | "FIXED" + customLswFixedPay?: number | null + private?: boolean + requiresKYC?: boolean + maxNumberOfParticipants?: number | null + token: string + exchangeRate: number +} + +export const useAdminUpdateContest = () => { + const queryClient = useQueryClient() + const { mutate, mutateAsync, ...mutation } = useMutation( + async (params) => { + try { + await contestsAPI.put(adminUpdateContestUrl(params.id), { + title: params.title, + short_description: params.shortDescription, + starts_at: params.startDate.toSeconds(), + ends_at: params.endDate.toSeconds(), + audit_rewards: params.auditRewards, + judging_prize_pool: params.judgingPrizePool, + lead_judge_fixed_pay: params.leadJudgeFixedPay, + full_payment: params.fullPayment, + lsw_payment_structure: params.lswPaymentStructure, + custom_lsw_fixed_pay: params.customLswFixedPay, + private: params.private, + requires_kyc: params.requiresKYC, + max_number_of_participants: params.maxNumberOfParticipants, + token: params.token, + exchange_rate: params.exchangeRate, + }) + } catch (error) { + const axiosError = error as AxiosError + throw Error(axiosError.response?.data.error ?? "Something went wrong. Please, try again.") + } + }, + { + onSuccess: async () => { + await queryClient.invalidateQueries(adminContestsQuery("active")) + await queryClient.invalidateQueries(adminContestsQuery("finished")) + }, + } + ) + + return { + updateContest: mutate, + ...mutation, + } +} diff --git a/src/hooks/api/contests.ts b/src/hooks/api/contests.ts index b2d4c820..9196e080 100644 --- a/src/hooks/api/contests.ts +++ b/src/hooks/api/contests.ts @@ -27,7 +27,6 @@ export type Contest = { judgingPrizePool?: number jugdingEndDate?: number // Timestamp in seconds. repo: string - linesOfCode?: string rewards: number judgingRepo: string escalationStartDate?: number // Timestamp in seconds. @@ -118,7 +117,6 @@ type GetContestResponseData = { judging_prize_pool: number | null judging_ends_at?: number template_repo_name: string - lines_of_code: string lead_judge_handle: string lead_judge_fixed_pay: number rewards: number @@ -153,7 +151,6 @@ export const useContest = (id: number) => judgingPrizePool: response.judging_prize_pool ?? undefined, jugdingEndDate: response.judging_ends_at, repo: response.template_repo_name, - linesOfCode: response.lines_of_code, rewards: response.rewards, leadJudgeHandle: response.lead_judge_handle, leadJudgeFixedPay: response.lead_judge_fixed_pay, diff --git a/src/hooks/api/contests/useProtocolDashboard.ts b/src/hooks/api/contests/useProtocolDashboard.ts index 3877e8f4..bc88ab50 100644 --- a/src/hooks/api/contests/useProtocolDashboard.ts +++ b/src/hooks/api/contests/useProtocolDashboard.ts @@ -24,7 +24,6 @@ export type ContestDetails = { submissionReady: boolean scopeReady: boolean startApproved: boolean - linesOfCode: string initialPaymentComplete: boolean fullPaymentComplete: boolean teamHandlesAdded: boolean @@ -60,7 +59,6 @@ type PaymentsResponse = { protocol_submission_ready: boolean scope_ready: boolean admin_start_approved: boolean - lines_of_code: string judging_prize_pool: number rewards: number initial_payment_complete: boolean @@ -100,7 +98,6 @@ export const useProtocolDashboard = (dashboardID: string) => submissionReady: data.contest.protocol_submission_ready, scopeReady: data.contest.scope_ready, startApproved: data.contest.admin_start_approved, - linesOfCode: data.contest.lines_of_code, initialPaymentComplete: data.contest.initial_payment_complete, fullPaymentComplete: data.contest.full_payment_complete, teamHandlesAdded: data.contest.team_handles_added, diff --git a/src/hooks/api/scope/useDeleteScope.ts b/src/hooks/api/scope/useDeleteScope.ts index 6ab4e3ad..7049ef2c 100644 --- a/src/hooks/api/scope/useDeleteScope.ts +++ b/src/hooks/api/scope/useDeleteScope.ts @@ -36,7 +36,7 @@ export const useDeleteScope = () => { const previousScope = queryClient.getQueryData(scopeQueryKey(params.protocolDashboardID)) - queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) => + queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) => previous?.filter((s) => s.repoName !== params.repoName) ) diff --git a/src/hooks/api/scope/useRepositoryContracts.ts b/src/hooks/api/scope/useRepositoryContracts.ts index c54007c8..78edd0c1 100644 --- a/src/hooks/api/scope/useRepositoryContracts.ts +++ b/src/hooks/api/scope/useRepositoryContracts.ts @@ -2,58 +2,121 @@ import { useQuery } from "react-query" import { contests as contestsAPI } from "../axios" import { getRepositoryContracts as getRepositoryContractsUrl } from "../urls" -type GetRepositoryContractsResponse = string[] +type GetRepositoryContractsResponse = { + file_path: string + nsloc?: number +}[] -export type TreeValue = Tree | string -export interface Tree extends Map {} - -export const getAllTreePaths = (parentPath: string, tree: TreeValue) => { - if (typeof tree === "string") { - return [`${parentPath}`] - } +type File = { + filepath: string + nsloc?: number +} - let paths: string[] = [] +interface BaseEntry { + name: string +} - tree.forEach((value, key) => { - paths = [...paths, ...getAllTreePaths(`${parentPath}/${key}`, value)] - }) +export interface FileEntry extends BaseEntry { + type: "file" + filepath: string + nsloc?: number +} - return paths +interface DirectoryEntry extends BaseEntry { + type: "directory" + entries: Array } -type Files = { - tree: Tree - rawPaths: string[] +export type Entry = FileEntry | DirectoryEntry + +type AnyEntry = RootDirectory | DirectoryEntry + +export interface RootDirectory { + type: "root" + entries: Array } -export const convertToTree = (paths: string[]) => { - const tree: Tree = new Map() +export const convertToTree2 = (files: File[]) => { + const root: RootDirectory = { + type: "root", + entries: [], + } + + files.forEach(({ filepath, nsloc }) => { + const parts = filepath.split("/") + + let current: AnyEntry = root - paths.forEach((d) => { - const parts = d.split("/") - let current: Tree = tree parts.forEach((p) => { if (p.endsWith(".sol") || p.endsWith(".vy")) { - current.set(p, p) + current.entries.push({ + type: "file", + name: p, + filepath, + nsloc, + }) } else { - if (!current.get(p)) current.set(p, new Map()) - current = current.get(p) as Tree + let directory: DirectoryEntry | undefined = current.entries.find( + (e) => e.type === "directory" && e.name === p + ) as DirectoryEntry + + if (!directory) { + directory = { + type: "directory", + name: p, + entries: [], + } + current.entries.push(directory) + } + + current = directory } }) }) - return tree + return root +} + +export type TreeValue = Tree | string +export interface Tree extends Map {} + +export const getAllTreePaths = (parentPath: string, tree: Entry) => { + if (tree.type === "file") { + return [`${parentPath}`] + } + + let paths: string[] = [] + + tree.entries.forEach((value, key) => { + paths = [...paths, ...getAllTreePaths(`${parentPath}/${value.name}`, value)] + }) + + return paths +} + +type RepositoryContracts = { + tree: RootDirectory + rawPaths: string[] } export const repositoryContractsQuery = (repo: string, commit: string) => ["repository-contracts", repo, commit] export const useRepositoryContracts = (repo: string, commit: string) => - useQuery(repositoryContractsQuery(repo, commit), async () => { - const { data } = await contestsAPI.get(getRepositoryContractsUrl(repo, commit)) + useQuery( + repositoryContractsQuery(repo, commit), + async () => { + const { data } = await contestsAPI.get(getRepositoryContractsUrl(repo, commit), { + timeout: 5 * 60 * 1000, + }) - const tree = convertToTree(data) + const tree = convertToTree2(data.map((f) => ({ filepath: f.file_path, nsloc: f.nsloc }))) - return { - tree, - rawPaths: data, + return { + tree, + rawPaths: data.map((f) => f.file_path), + } + }, + { + enabled: !!repo && !!commit, + refetchOnWindowFocus: false, } - }) + ) diff --git a/src/hooks/api/scope/useScope.ts b/src/hooks/api/scope/useScope.ts index 68ffd22a..0388ac05 100644 --- a/src/hooks/api/scope/useScope.ts +++ b/src/hooks/api/scope/useScope.ts @@ -6,38 +6,59 @@ export type Scope = { repoName: string branchName: string commitHash: string - files: string[] + files: { + filePath: string + nSLOC?: number + selected: boolean + }[] solidityMetricsReport?: string - nSLOC?: number commentToSourceRatio?: number -}[] + initialScope?: Scope + nSLOC?: number +} -export type GetScopeResponse = { - scope: { - repo_name: string - branch_name: string - commit_hash: string - files: string[] +type ScopeResponse = { + repo_name: string + branch_name: string + commit_hash: string + files: { + file_path: string nsloc?: number - comment_to_source_ratio?: number + selected: boolean }[] + comment_to_source_ratio?: number + initial_scope?: Omit + nsloc?: number +} + +export type GetScopeResponse = { + scope: ScopeResponse[] +} + +function parseScope(d: ScopeResponse): Scope { + return { + repoName: d.repo_name, + branchName: d.branch_name, + commitHash: d.commit_hash, + files: d.files.map((f) => ({ + filePath: f.file_path, + nSLOC: f.nsloc, + selected: f.selected, + })), + commentToSourceRatio: d.comment_to_source_ratio, + initialScope: d.initial_scope ? parseScope(d.initial_scope) : undefined, + nSLOC: d.nsloc, + } } export const scopeQueryKey = (dashboardID?: string) => ["scope", dashboardID] export const useScope = (dashboardID?: string) => - useQuery( + useQuery( scopeQueryKey(dashboardID), async () => { const { data } = await contestsAPI.get(getScopeUrl(dashboardID ?? "")) - return data.scope.map((d) => ({ - repoName: d.repo_name, - branchName: d.branch_name, - commitHash: d.commit_hash, - files: d.files, - nSLOC: d.nsloc, - commentToSourceRatio: d.comment_to_source_ratio, - })) + return data.scope.map(parseScope) }, { enabled: !!dashboardID, diff --git a/src/hooks/api/scope/useUpdateScope.ts b/src/hooks/api/scope/useUpdateScope.ts index 07b862f3..97a205ac 100644 --- a/src/hooks/api/scope/useUpdateScope.ts +++ b/src/hooks/api/scope/useUpdateScope.ts @@ -14,7 +14,7 @@ type UpdateScopeParams = { } type UpdateScopeContext = { - previousScope?: Scope + previousScope?: Scope[] } export const useUpdateScope = () => { @@ -38,11 +38,9 @@ export const useUpdateScope = () => { }, { onMutate: async (params) => { - await queryClient.invalidateQueries(scopeQueryKey(params.protocolDashboardID)) + const previousScope = queryClient.getQueryData(scopeQueryKey(params.protocolDashboardID)) - const previousScope = queryClient.getQueryData(scopeQueryKey(params.protocolDashboardID)) - - queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) => { + queryClient.setQueryData(scopeQueryKey(params.protocolDashboardID), (previous) => { if (!previous) return const scopeIndex = previous.findIndex((s) => s.repoName === params.repoName) @@ -54,7 +52,13 @@ export const useUpdateScope = () => { repoName: previous[scopeIndex].repoName, branchName: params.branchName ?? previous[scopeIndex].branchName, commitHash: params.commitHash ?? previous[scopeIndex].commitHash, - files: params.files ?? previous[scopeIndex].files, + files: params.files + ? previous[scopeIndex].files.map((f) => ({ + ...f, + selected: params.files!.includes(f.filePath), + })) + : previous[scopeIndex].files, + initialScope: previous[scopeIndex].initialScope, }, ...previous.slice(scopeIndex + 1), ] diff --git a/src/hooks/api/stats.ts b/src/hooks/api/stats.ts index 142471be..ea63cb7a 100644 --- a/src/hooks/api/stats.ts +++ b/src/hooks/api/stats.ts @@ -5,6 +5,7 @@ import { getAPYOverTime as getAPYOverTimeUrl, getTVCOverTime as getTVCOverTimeUrl, getTVLOverTime as getTVLOverTimeUrl, + getExternalCoverageOverTime as getExternalCoverageOverTimeUrl, } from "./urls" type APYDataPoint = { @@ -81,6 +82,22 @@ export const useTVCOverTime = () => .sort((a, b) => a.timestamp - b.timestamp) }) +export const externalCoverageOverTimeQueryKey = "externalCoverageOverTime" +export const useExternalCoverageOverTime = () => + useQuery[] | null, Error>(externalCoverageOverTimeQueryKey, async () => { + const { data: response } = await axios.get(getExternalCoverageOverTimeUrl()) + + if (response.ok === false) throw Error(response.error) + if (response.data === null) return null + + return response.data + .map((r) => ({ + timestamp: r.timestamp, + value: BigNumber.from(r.value), + })) + .sort((a, b) => a.timestamp - b.timestamp) + }) + export const tvlOverTimeQueryKey = "tvlOverTime" export const useTVLOverTime = () => useQuery[] | null, Error>(tvlOverTimeQueryKey, async () => { diff --git a/src/hooks/api/urls.ts b/src/hooks/api/urls.ts index 03afa6b9..60c0935a 100644 --- a/src/hooks/api/urls.ts +++ b/src/hooks/api/urls.ts @@ -5,6 +5,7 @@ export const getUnlockOverTime = () => "stats/unlock" export const getAPYOverTime = () => "stats/apy" export const getTVLOverTime = () => "stats_tvl" export const getTVCOverTime = () => "stats_tvc" +export const getExternalCoverageOverTime = () => "stats_external_coverage" export const getStakePositions = (account?: string) => (account ? `staking/${account}` : "staking") export const getCoveredProtocols = () => "protocols" export const getLastIndexedBlock = () => `last-block-indexed` @@ -67,13 +68,21 @@ export const adminApproveStart = () => `/admin/approve_start` export const getAdminProtocol = (name: string) => `/admin/protocol/${name}` export const getAdminContestScope = (contestID: number) => `/admin/contest/${contestID}/scope` export const adminCreateContest = () => `/admin/contests` +export const adminUpdateContest = (contestID: number) => `/admin/contests/${contestID}` +export const adminConfirmContest = (contestID: number) => `/admin/contests/${contestID}/confirm` +export const adminResetScope = (contestID: number, scopeType: "initial" | "final") => + `/admin/contests/${contestID}/scope/${scopeType}` +export const adminResetQA = (contestID: number) => `/admin/contests/${contestID}/reset_qa` export const getAdminTwitterAccount = (handle: string) => `/admin/twitter_account/${handle}` +export const getAdminProtocolContests = (protocolID: number) => `/admin/protocol_contests/${protocolID}` export const adminSubmitScope = () => `/admin/scope` export const getSeniorWatson = (handle: string) => `/admin/senior_watson?handle=${handle}` export const adminStartLeadSeniorWatsonSelection = () => `/admin/start_lead_senior_watson_selection` export const adminSelectLeadSeniorWatson = () => `/admin/select_lead_senior_watson` export const adminGenerateReport = (contestID: number) => `/admin/contest/${contestID}/report/generate` export const adminPublishReport = (contestID: number) => `/admin/contest/${contestID}/report/publish` +export const adminDeleteDraftContest = (contestID: number) => `/admin/contests/${contestID}` +export const getAdminContest = (contestID: number) => `/admin/contests/${contestID}` // Stats export const getLeaderboard = () => "/stats/leaderboard" diff --git a/src/pages/AuditScope/AuditScope.module.scss b/src/pages/AuditScope/AuditScope.module.scss index 1359cf25..a84bf468 100644 --- a/src/pages/AuditScope/AuditScope.module.scss +++ b/src/pages/AuditScope/AuditScope.module.scss @@ -77,6 +77,11 @@ opacity: 1; } } + + .addedNSLOC { + color: #58c322; + font-weight: bold; + } } } } @@ -104,3 +109,20 @@ } } } + +.nslocDiff { + color: #ffa837 !important; +} + +.fileAdded { + color: #91e467 !important; +} + +.fileRemoved { + color: #ef3131 !important; + opacity: 1 !important; +} + +.fileSame { + color: $primary-purple !important; +} diff --git a/src/pages/AuditScope/AuditScope.tsx b/src/pages/AuditScope/AuditScope.tsx index 02df44a4..56b97f0c 100644 --- a/src/pages/AuditScope/AuditScope.tsx +++ b/src/pages/AuditScope/AuditScope.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react" -import { FaGithub, FaTrashAlt } from "react-icons/fa" +import { FaCheckCircle, FaGithub, FaMinusCircle, FaPlusCircle, FaRegDotCircle, FaTrashAlt } from "react-icons/fa" import { Box } from "../../components/Box" import { Button } from "../../components/Button" import { Input } from "../../components/Input" @@ -21,6 +21,9 @@ import { AuditScopeReadOnly } from "./AuditScopeReadOnly" import Modal, { Props as ModalProps } from "../../components/Modal/Modal" import { useSubmitScope } from "../../hooks/api/contests/useSubmitScope" import { ErrorModal } from "../ContestDetails/ErrorModal" +import { convertToTree2 } from "../../hooks/api/scope/useRepositoryContracts" + +import styles from "./AuditScope.module.scss" type Props = ModalProps & { dashboardID: string @@ -106,8 +109,10 @@ export const AuditScope = () => { const handlePathSelected = useCallback( (repo: string, paths: string[]) => { - console.log(paths) - let selectedPaths = scope?.find((s) => s.repoName === repo)?.files + let selectedPaths = scope + ?.find((s) => s.repoName === repo) + ?.files.filter((f) => f.selected) + .map((f) => f.filePath) if (!selectedPaths) return @@ -179,14 +184,14 @@ export const AuditScope = () => { ) const handleSelectAll = useCallback( - (repoName: string, files: string[]) => { - const repo = scope?.find((s) => s.repoName === repoName) - if (!repo) return + (repoName: string) => { + const s = scope?.find((s) => s.repoName === repoName) + if (!s) return updateScope({ protocolDashboardID: dashboardID ?? "", repoName, - files, + files: s.files.map((f) => f.filePath), }) }, [updateScope, dashboardID, scope] @@ -226,80 +231,155 @@ export const AuditScope = () => { {scope && scope.length > 0 ? ( - - - Branches & commits - - Select branch and commit hash - - - - Repo + <> + + + Branches & commits + + Select branch and commit hash + + + + Repo + + {scope?.map((s) => ( + + ))} + + + + Branch + + {scope?.map((s) => ( + + ))} + + + + Commit hash + + {scope?.map((s) => ( + + ))} + + + Remove + {scope?.map((s) => ( + + ))} + + + + + + + + Legend + + + + + File selected in both original and current scope. No change in nSLOC. + + + + + File selected in both original and current scope. nSLOC changed. + + + + File added to scope + + + + File removed from scope + + + + + + ) : null} + {scope?.map((s) => { + const selectedNSLOC = s.files.reduce((t, f) => (t += f.selected ? f.nSLOC ?? 0 : 0), 0) + const initialNSLOC = s.initialScope?.files.reduce((t, f) => (t += f.selected ? f.nSLOC ?? 0 : 0), 0) + const differenceNSLOC = initialNSLOC && selectedNSLOC - initialNSLOC + + return ( + + + + <FaGithub /> +   + {s.repoName} + + + + Original Scope from Quote + + + + Files: {s.initialScope?.files.filter((f) => f.selected).length} - {scope?.map((s) => ( - - ))} - - - - Branch + + nSLOC: {initialNSLOC} - {scope?.map((s) => ( - - ))} - - - - Commit hash + + + + + Currently Selected Scope + + + + Files: {s.files.filter((f) => f.selected).length} - {scope?.map((s) => ( - - ))} - - - Remove - {scope?.map((s) => ( - - ))} - - + + nSLOC: {s.files.reduce((t, f) => (t += f.selected ? f.nSLOC ?? 0 : 0), 0)} + + + + + {differenceNSLOC ? ( + + + Changes from Original Scope: + + 0 ? "success" : "info"}> + {`${differenceNSLOC > 0 ? "+" : ""}${differenceNSLOC}`} nSLOC + + + ) : null} + ({ + filepath: f.filePath, + nsloc: f.nSLOC ?? 0, + })) ?? [] + )} + onPathSelected={(paths) => handlePathSelected(s.repoName, paths)} + selectedPaths={s.files.filter((f) => f.selected).map((f) => f.filePath)} + onSelectAll={() => handleSelectAll(s.repoName)} + onClearSelection={() => handleClearSelection(s.repoName)} + initialScope={s.initialScope} + /> - - - ) : null} - {scope?.map((s) => ( - - - - <FaGithub /> -   - {s.repoName} - - handlePathSelected(s.repoName, paths)} - selectedPaths={s.files} - onSelectAll={(files) => handleSelectAll(s.repoName, files)} - onClearSelection={() => handleClearSelection(s.repoName)} - /> - - - ))} + + ) + })} @@ -182,6 +221,23 @@ export const RepositoryContractsSelector: React.FC = ({ Clear selection + + + Files + + + {initialScope ? ( + + Original nSLOC | Current nSLOC | Diff + + ) : ( + + nSLOC + + )} + + +
    {treeElements}
) diff --git a/src/pages/AuditScope/ScopeDiffModal.tsx b/src/pages/AuditScope/ScopeDiffModal.tsx new file mode 100644 index 00000000..5fcb8f72 --- /dev/null +++ b/src/pages/AuditScope/ScopeDiffModal.tsx @@ -0,0 +1,7 @@ +import Modal, { Props as ModalProps } from "../../components/Modal/Modal" + +type Props = ModalProps & {} + +export const ScopeDiffModal: React.FC = ({ onClose }) => { + return +} diff --git a/src/pages/AuditScope/ScopeList.tsx b/src/pages/AuditScope/ScopeList.tsx index 74ea4774..be27e5f5 100644 --- a/src/pages/AuditScope/ScopeList.tsx +++ b/src/pages/AuditScope/ScopeList.tsx @@ -3,25 +3,32 @@ import { Box } from "../../components/Box" import { Column } from "../../components/Layout" import { Text } from "../../components/Text" import { Title } from "../../components/Title" -import { convertToTree } from "../../hooks/api/scope/useRepositoryContracts" +import { convertToTree2 } from "../../hooks/api/scope/useRepositoryContracts" import { Scope } from "../../hooks/api/scope/useScope" import { TreeEntry } from "./RepositoryContractsSelector" import styles from "./AuditScope.module.scss" type Props = { - scope: Scope + scope: Scope[] } export const ScopeList: React.FC = ({ scope }) => { return ( {scope?.map((s) => { - const tree = convertToTree(s.files) + const tree = convertToTree2( + s.files + .filter((f) => f.selected) + .map((f) => ({ + filepath: f.filePath, + nsloc: f.nSLOC ?? 0, + })) ?? [] + ) const treeElements: React.ReactNode[] = [] - tree.forEach((value, key) => { - treeElements.push() + tree.entries.forEach((value, key) => { + treeElements.push() }) return ( diff --git a/src/pages/Claim/ClaimStatusAction.tsx b/src/pages/Claim/ClaimStatusAction.tsx index f5757285..5a8a4d80 100644 --- a/src/pages/Claim/ClaimStatusAction.tsx +++ b/src/pages/Claim/ClaimStatusAction.tsx @@ -187,10 +187,10 @@ const Escalate: React.FC = ({ claim, protocol }) => { {!connectedAccountIsClaimInitiator && ( <> - Only the claim inintiator can escalate it to UMA. + Only the claim initiator can escalate it to UMA. - Please connnect using account with address {shortenAddress(claim.initiator)} + Please connect using account with address {shortenAddress(claim.initiator)} )} diff --git a/src/pages/Claim/Claims.tsx b/src/pages/Claim/Claims.tsx index 901ea73a..205fa34e 100644 --- a/src/pages/Claim/Claims.tsx +++ b/src/pages/Claim/Claims.tsx @@ -44,7 +44,7 @@ export const ClaimsPage: React.FC = () => { /** * Handler for protocol's select option change */ - const handleProtocolChanged = useCallback((option: string) => { + const handleProtocolChanged = useCallback((option?: string) => { setSelectedProtocolBytesIdentifier(option) }, []) diff --git a/src/pages/Claim/Field.tsx b/src/pages/Claim/Field.tsx index f555bf96..c5971495 100644 --- a/src/pages/Claim/Field.tsx +++ b/src/pages/Claim/Field.tsx @@ -19,7 +19,7 @@ type Props = { export const Field: React.FC> = ({ label, detail, children, sublabel, ...props }) => { return ( - + {(label || props.error) && ( @@ -43,7 +43,9 @@ export const Field: React.FC> = ({ label, detail, child )} - {children} + + {children} + {detail && ( {detail} diff --git a/src/pages/FundraisingClaim/FundraisingClaim.tsx b/src/pages/FundraisingClaim/FundraisingClaim.tsx index dd3a76b3..c1c43464 100644 --- a/src/pages/FundraisingClaim/FundraisingClaim.tsx +++ b/src/pages/FundraisingClaim/FundraisingClaim.tsx @@ -71,7 +71,7 @@ export const FundraisingClaimPage = () => { } fetchClaimIsActive() - }) + }, [sherClaim]) const handleOnSuccess = useCallback( async (blockNumber: number) => { @@ -130,7 +130,7 @@ export const FundraisingClaimPage = () => { - Claiming {fundraisePositionData?.claimableAt < DateTime.now() ? "started" : "starts in"} + Claiming {fundraisePositionData?.claimableAt < DateTime.now() ? " started" : " starts in"} diff --git a/src/pages/Overview/ExcessCoverageChart.tsx b/src/pages/Overview/ExcessCoverageChart.tsx index 1b670570..9d16a39d 100644 --- a/src/pages/Overview/ExcessCoverageChart.tsx +++ b/src/pages/Overview/ExcessCoverageChart.tsx @@ -1,6 +1,6 @@ import { Chart } from "../../components/Chart/Chart" import { DateTime } from "luxon" -import { useTVCOverTime } from "../../hooks/api/stats" +import { useExternalCoverageOverTime } from "../../hooks/api/stats" import { useMemo } from "react" import { utils } from "ethers" @@ -17,11 +17,11 @@ const tooltipTitles: Record = { const nexusStartDate = DateTime.fromSeconds(config.nexusMutualStartTimestamp) export const ExcessCoverageChart = () => { - const { data: tvcData } = useTVCOverTime() + const { data: externalCoverageData } = useExternalCoverageOverTime() const chartData = useMemo( () => - tvcData?.reduce<{ name: number; value: number }[]>((dataPoints, item) => { + externalCoverageData?.reduce<{ name: number; value: number }[]>((dataPoints, item) => { const date = DateTime.fromSeconds(item.timestamp) if (nexusStartDate.diff(date, "days").days < 5) { @@ -31,13 +31,13 @@ export const ExcessCoverageChart = () => { dataPoints.push({ name: item.timestamp, - value: date > nexusStartDate ? Number(utils.formatUnits(item.value.mul(25).div(100), 6)) : 0, + value: Number(utils.formatUnits(item.value, 6)), }) } return dataPoints }, []), - [tvcData] + [externalCoverageData] ) const totalAmount = useMemo(() => { diff --git a/src/pages/Overview/Overview.tsx b/src/pages/Overview/Overview.tsx index 661606f6..76dab00c 100644 --- a/src/pages/Overview/Overview.tsx +++ b/src/pages/Overview/Overview.tsx @@ -6,16 +6,14 @@ import { Column, Row } from "../../components/Layout" import { Title } from "../../components/Title" import { Chart } from "../../components/Chart/Chart" -import { useTVCOverTime, useTVLOverTime } from "../../hooks/api/stats" +import { useTVCOverTime, useTVLOverTime, useExternalCoverageOverTime } from "../../hooks/api/stats" import styles from "./Overview.module.scss" import APYChart from "../../components/APYChart/APYChart" import CoveredProtocolsList from "../../components/CoveredProtocolsList/CoveredProtocolsList" import { formatAmount } from "../../utils/format" import StrategiesList from "../../components/StrategiesList/StrategiesList" -import config from "../../config" import { ExcessCoverageChart } from "./ExcessCoverageChart" -import { Text } from "../../components/Text" import ClaimsList from "../../components/ClaimsList/ClaimsList" type ChartDataPoint = { @@ -26,33 +24,19 @@ type ChartDataPoint = { export const OverviewPage: React.FC = () => { const { data: tvlData } = useTVLOverTime() const { data: tvcData } = useTVCOverTime() + const { data: externalCoverageData } = useExternalCoverageOverTime() const chartsData = useMemo(() => { - if (!tvlData || !tvcData) return + if (!tvlData || !tvcData || !externalCoverageData) return const tvcChartData: ChartDataPoint[] = [] const tvlChartData: ChartDataPoint[] = [] const capitalEfficiencyChartData: ChartDataPoint[] = [] - for (let i = 0, j = 0; i < tvlData.length && j < tvcData.length; ) { - const tvcDataPointDate = DateTime.fromSeconds(tvcData[i].timestamp) - const tvlDataPointDate = DateTime.fromSeconds(tvlData[j].timestamp) - - let tvc = tvcData[i] - let tvl = tvlData[j] - - if (tvcDataPointDate.day !== tvlDataPointDate.day) { - if (tvcDataPointDate < tvlDataPointDate) { - tvl = j > 0 ? tvlData[j - 1] : tvlData[j] - i++ - } else { - tvc = i > 0 ? tvcData[i - 1] : tvcData[i] - j++ - } - } else { - i++ - j++ - } + for (let i = 0; i < tvcData.length; i++) { + const tvc = tvcData[i] + const tvl = tvlData[i] + const externalCoverage = externalCoverageData[i] const timestamp = Math.min(tvc.timestamp, tvl.timestamp) @@ -66,9 +50,10 @@ export const OverviewPage: React.FC = () => { value: Number(utils.formatUnits(tvl.value, 6)), } - // TVC is increased by 25% due to our agreement with Nexus. - // To calculate capital efficiency, we only used what is being covered by Sherlock's staking pool. - const sherlockTVC = timestamp > config.nexusMutualStartTimestamp ? tvc.value.mul(75).div(100) : tvc.value + // External coverage is subtracted from the total TVC due to our agreement with Nexus + // and only a part of the TVC is covereged by Sherlock's staking pool. + // const sherlockTVC = timestamp > config.nexusMutualStartTimestamp ? tvc.value.mul(75).div(100) : tvc.value + const sherlockTVC = tvc.value.sub(externalCoverage.value) const capitalEfficiencyDataPoint = { name: timestamp, @@ -83,7 +68,10 @@ export const OverviewPage: React.FC = () => { tvcChartData.push(tvcDataPoint) tvlChartData.push(tvlDataPoint) - capitalEfficiencyChartData.push(capitalEfficiencyDataPoint) + + if (capitalEfficiencyDataPoint.value > 0) { + capitalEfficiencyChartData.push(capitalEfficiencyDataPoint) + } } return { @@ -91,7 +79,7 @@ export const OverviewPage: React.FC = () => { tvlChartData, capitalEfficiencyChartData, } - }, [tvlData, tvcData]) + }, [tvlData, tvcData, externalCoverageData]) return ( diff --git a/src/pages/Protocol/Protocol.tsx b/src/pages/Protocol/Protocol.tsx index 94eadbb6..b6b63661 100644 --- a/src/pages/Protocol/Protocol.tsx +++ b/src/pages/Protocol/Protocol.tsx @@ -28,16 +28,25 @@ export const ProtocolPage: React.FC = () => { const { data: protocols } = useProtocols() const { address: connectedAddress } = useAccount() - const protocolSelectOptions = React.useMemo( - () => - Object.entries(protocols ?? {}) + const protocolSelectOptions = React.useMemo(() => { + const options = [ + ...(!selectedProtocolId + ? [ + { + value: undefined, + label: "Select Protocol", + }, + ] + : []), + ...(Object.entries(protocols ?? {}) .filter(([_, p]) => p.agent !== ethers.constants.AddressZero) .map(([key, item]) => ({ label: item.name ?? "Unknown", value: key as `0x${string}`, - })) ?? [], - [protocols] - ) + })) ?? []), + ] + return options + }, [protocols, selectedProtocolId]) const selectedProtocol = React.useMemo( () => (selectedProtocolId ? protocols?.[selectedProtocolId] ?? null : null), [selectedProtocolId, protocols] @@ -59,7 +68,9 @@ export const ProtocolPage: React.FC = () => { if (!protocols) return if (protocolTag) { - const found = Object.entries(protocols).find(([_, p]) => p.tag === protocolTag) + const found = Object.entries(protocols).find( + ([_, p]) => (p.name && p.name.toLowerCase().replaceAll(" ", "_") === protocolTag) || p.tag === protocolTag + ) if (found) { const [_, protocol] = found @@ -71,7 +82,7 @@ export const ProtocolPage: React.FC = () => { /** * Handler for changing the protocol */ - const handleOnProtocolChanged = React.useCallback((option: `0x${string}`) => { + const handleOnProtocolChanged = React.useCallback((option?: `0x${string}`) => { setSelectedProtocolId(option) }, []) diff --git a/src/pages/Staking/Staking.tsx b/src/pages/Staking/Staking.tsx index cb4d44b1..9c22b537 100644 --- a/src/pages/Staking/Staking.tsx +++ b/src/pages/Staking/Staking.tsx @@ -147,9 +147,6 @@ export const StakingPage: React.FC = () => { - - The Summer 2023 Staking Round is currently open! - Stake diff --git a/src/pages/admin/AdminContestsList/AdminContestListDraft.tsx b/src/pages/admin/AdminContestsList/AdminContestListDraft.tsx new file mode 100644 index 00000000..465b6a56 --- /dev/null +++ b/src/pages/admin/AdminContestsList/AdminContestListDraft.tsx @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useState } from "react" +import { + FaCaretDown, + FaCaretRight, + FaCaretUp, + FaClipboardList, + FaFilter, + FaTrash, + FaUndo, + FaUsers, +} from "react-icons/fa" +import { Box } from "../../../components/Box" +import { Button } from "../../../components/Button" +import { Column, Row } from "../../../components/Layout" +import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer" +import { Table, TBody, Td, Th, THead, Tr } from "../../../components/Table/Table" +import { Text } from "../../../components/Text" +import { Title } from "../../../components/Title" +import { useAdminContests } from "../../../hooks/api/admin/useAdminContests" + +import styles from "./AdminContestsList.module.scss" +import { ConfirmContestModal } from "./ConfirmContestModal" +import { ContestResetInitialScopeModal } from "./ContestResetInitialScopeModal" +import { DeleteDraftContestModal } from "./DeleteDraftContestModal" +import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/Tooltip/Tooltip" +import { DateTime } from "luxon" +import { useNavigate } from "react-router-dom" + +type FilterType = "ALL" | "ONLY_SCOPE_SUBMITTED" | "ONLY_SCOPE_NOT_SUBMITTED" + +const FilterLabels: Record = { + ALL: "All contests", + ONLY_SCOPE_SUBMITTED: "Only with submitted scope", + ONLY_SCOPE_NOT_SUBMITTED: "Only without submitted scope", +} + +export const AdminContestListDraft = () => { + const { data: contests, isLoading } = useAdminContests("draft") + + const [confirmContestIndex, setConfirmContestIndex] = useState() + const [resetContestIndex, setResetContestIndex] = useState() + const [deleteContestIndex, setDeleteContestIndex] = useState() + + const [activeFilter, setActiveFilter] = useState("ALL") + const [collapsed, setCollapsed] = useState(true) + + const visibleContests = useMemo(() => { + switch (activeFilter) { + case "ALL": + return contests + case "ONLY_SCOPE_SUBMITTED": + return contests + ?.filter((item) => item.initialScopeSubmitted) + .sort((a, b) => (b.initialScopeSubmittedAt ?? 0) - (a.initialScopeSubmittedAt ?? 0)) + case "ONLY_SCOPE_NOT_SUBMITTED": + return contests?.filter((item) => !item.initialScopeSubmitted) + } + }, [contests, activeFilter]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "f") { + setCollapsed(false) + } + } + + document.addEventListener("keydown", handleKeyDown) + + return () => { + document.removeEventListener("keydown", handleKeyDown) + } + }, []) + + return ( + + + + + + DRAFTS + + + {!collapsed && ( + + +
+ + Filter +
+ {FilterLabels[activeFilter]} +
+
+
+ +
+ {Object.entries(FilterLabels).map(([value, label]) => ( +
setActiveFilter(value as FilterType)}> + {label} +
+ ))} +
+
+
+ )} +
+ {!collapsed && ( + + + + + + + + + + + + {visibleContests?.map((c, index) => { + return ( + + + + + + + + ) + })} + +
+ ID + + Contest + StatusAction
{c.id} + + {c.title} + {c.title} + + + + + + + + + + + + {c.initialScopeSubmittedAt + ? `Initial scope submitted at ${DateTime.fromSeconds( + c.initialScopeSubmittedAt + ).toLocaleString(DateTime.DATETIME_SHORT)}` + : "Waiting on initial scope"} + + + + + +
+ )} + {confirmContestIndex !== undefined && contests && ( + setConfirmContestIndex(undefined)} + contest={contests[confirmContestIndex]} + /> + )} + {resetContestIndex !== undefined && contests && ( + setResetContestIndex(undefined)} + contest={contests[resetContestIndex]} + /> + )} + {deleteContestIndex !== undefined && contests && ( + setDeleteContestIndex(undefined)} + contest={contests[deleteContestIndex]} + /> + )} +
+
+
+ ) +} diff --git a/src/pages/admin/AdminContestsList/AdminContestsList.module.scss b/src/pages/admin/AdminContestsList/AdminContestsList.module.scss index 43b8f8f8..796e049d 100644 --- a/src/pages/admin/AdminContestsList/AdminContestsList.module.scss +++ b/src/pages/admin/AdminContestsList/AdminContestsList.module.scss @@ -24,10 +24,10 @@ width: 30%; } &:nth-child(3) { - width: 20%; + width: 25%; } &:nth-child(4) { - width: 45%; + width: 40%; } &:nth-child(5) { width: 12rem; @@ -76,3 +76,56 @@ background-color: transparentize($color: #ffcc00, $amount: 0.9); color: #ffcc00; } + +.telegram { + opacity: 0.6; + display: flex; + font-weight: bold; + + & *:first-child { + margin-right: 5px; + } +} + +.filterContainer { + cursor: pointer; + user-select: none; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + transition: all 0.1s linear; + color: rgb(156, 163, 175); + + &:hover { + color: rgb(209, 213, 219); + } + + .activeFilter { + background-color: rgb(209, 213, 219); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 100px; + padding: 4px 8px; + text-align: center; + font-size: 14px; + font-weight: 600; + color: black; + } +} + +.filtersList { + display: flex; + flex-direction: column; + + div { + cursor: pointer; + padding: 16px 32px; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + } +} diff --git a/src/pages/admin/AdminContestsList/AdminContestsList.tsx b/src/pages/admin/AdminContestsList/AdminContestsList.tsx index 483fea5b..d45dab89 100644 --- a/src/pages/admin/AdminContestsList/AdminContestsList.tsx +++ b/src/pages/admin/AdminContestsList/AdminContestsList.tsx @@ -1,12 +1,30 @@ -import { Column } from "../../../components/Layout" +import { useState } from "react" +import { FaPlus } from "react-icons/fa" +import { Box } from "../../../components/Box" +import { Button } from "../../../components/Button" +import { Column, Row } from "../../../components/Layout" +import { AdminContestListDraft } from "./AdminContestListDraft" import { AdminContestsListActive } from "./AdminContestsListActive" import { AdminContestsListFinished } from "./AdminContestsListFinished" +import { CreateContestModal } from "./CreateContestModal" export const AdminContestsList = () => { + const [createContestModalOpen, setCreateContestModalOpen] = useState(false) + return ( + + + + + + + {createContestModalOpen && setCreateContestModalOpen(false)} />} ) } diff --git a/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx b/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx index f852801e..0f987477 100644 --- a/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx +++ b/src/pages/admin/AdminContestsList/AdminContestsListActive.tsx @@ -1,6 +1,6 @@ import { DateTime } from "luxon" -import { useCallback, useState } from "react" -import { FaClipboardList, FaEye, FaFastForward, FaPlus, FaBullseye } from "react-icons/fa" +import { useCallback, useEffect, useState } from "react" +import { FaClipboardList, FaFastForward, FaPlus, FaEdit, FaRegListAlt, FaUsers } from "react-icons/fa" import { Box } from "../../../components/Box" import { Button } from "../../../components/Button" import { Column, Row } from "../../../components/Layout" @@ -12,8 +12,11 @@ import { ContestsListItem, useAdminContests } from "../../../hooks/api/admin/use import styles from "./AdminContestsList.module.scss" import { ConfirmContestActionModal } from "./ConfirmContestActionModal" -import { CreateContestModal } from "./CreateContestModal" import { ContestScopeModal } from "./ContestScopeModal" +import { UpdateContestModal } from "./UpdateContestModal" +import { TelegramBotIndicator } from "./TelegramBotIndicator" +import { GenerateReportSuccessModal } from "./GenerateReportSuccessModal" +import { useAdminGenerateReport } from "../../../hooks/api/admin/useGenerateReport" type ContestLifeCycleStatus = | "WAITING_INITIAL_PAYMENT" @@ -21,7 +24,6 @@ type ContestLifeCycleStatus = | "WAITING_FOR_SENIOR_SELECTION" | "READY_TO_PUBLISH" | "WAITING_ON_FINAL_PAYMENT" - | "WAITING_ON_FINALIZE_SUBMISSION" | "READY_TO_APPROVE_START" | "START_APPROVED" | "RUNNING" @@ -29,22 +31,22 @@ type ContestLifeCycleStatus = | "ESCALATING" | "SHERLOCK_JUDGING" | "FINISHED" + | "DRAFT" + | "FINAL_REPORT_AVAILABLE" + | "FINAL_REPORT_AVAILABLE_TO_GENERATE" const getCurrentStatus = (contest: ContestsListItem): ContestLifeCycleStatus => { if (!contest.initialPayment) return "WAITING_INITIAL_PAYMENT" if (!contest.leadSeniorAuditorHandle && !contest.leadSeniorSelectionMessageSentAt) return "READY_TO_SELECT_SENIOR" if (!contest.leadSeniorAuditorHandle) return "WAITING_FOR_SENIOR_SELECTION" if (!contest.adminUpcomingApproved) return "READY_TO_PUBLISH" - if (!contest.fullPayment) return "WAITING_ON_FINAL_PAYMENT" - if (!contest.submissionReady) return "WAITING_ON_FINALIZE_SUBMISSION" + if (!contest.fullPaymentComplete) return "WAITING_ON_FINAL_PAYMENT" if (!contest.adminStartApproved) return "READY_TO_APPROVE_START" + if (contest.status === "CREATED") return "START_APPROVED" + if (contest.auditReport) return "FINAL_REPORT_AVAILABLE" + if (contest.finalReportAvailable) return "FINAL_REPORT_AVAILABLE_TO_GENERATE" - switch (contest.status) { - case "CREATED": - return "START_APPROVED" - default: - return contest.status - } + return contest.status } const getForcedStatus = (contest: ContestsListItem): ContestLifeCycleStatus | undefined => { @@ -74,9 +76,29 @@ type ConfirmationModal = { export const AdminContestsListActive = () => { const { data: contests, isLoading } = useAdminContests("active") const [confirmationModal, setConfirmationModal] = useState() - const [createContestModalOpen, setCreateContestModalOpen] = useState(false) + const [updateContestIndex, setUpdateContextIndex] = useState() const [scopeModal, setScopeModal] = useState() const [forceActionRowIndex, setForceActionRowIndex] = useState() + const [reportGeneratedModalVisible, setReportGenerateModalVisible] = useState() + const { + generateReport, + isLoading: generateReportIsLoading, + isSuccess, + reset, + variables, + data: reportURL, + } = useAdminGenerateReport() + + useEffect(() => { + if (isSuccess) { + const index = contests?.findIndex((c) => c.id === variables?.contestID) + setReportGenerateModalVisible(index) + console.log("undef") + } else { + setReportGenerateModalVisible(undefined) + console.log("undef") + } + }, [isSuccess, setReportGenerateModalVisible, variables, contests]) const handleActionClick = useCallback( (contestIndex: number, action: ContestAction) => { @@ -111,6 +133,33 @@ export const AdminContestsListActive = () => { }) }, []) + const handleModalClose = useCallback(() => { + console.log("close") + reset() + setReportGenerateModalVisible(undefined) + }, [reset]) + + const handleGenerateReportClick = useCallback( + (index: number) => { + if (!contests) return + + const contest = contests[index] + generateReport({ contestID: contest.id }) + }, + [generateReport, contests] + ) + + const handleViewReportClick = useCallback( + (index: number) => { + if (!contests) return + + setReportGenerateModalVisible(index) + + console.log(contests[index]) + }, + [contests, setReportGenerateModalVisible] + ) + const renderContestAction = useCallback( (contestIndex: number) => { if (!contests) return null @@ -151,13 +200,28 @@ export const AdminContestsListActive = () => { ) + if (status === "FINAL_REPORT_AVAILABLE_TO_GENERATE") + return ( + + ) + + if (status === "FINAL_REPORT_AVAILABLE") + return + return ( ) }, - [contests, handleActionClick, forceActionRowIndex] + [contests, handleActionClick, forceActionRowIndex, handleGenerateReportClick, handleViewReportClick] ) const renderContestState = useCallback( @@ -176,9 +240,7 @@ export const AdminContestsListActive = () => { } if (status === "WAITING_FOR_SENIOR_SELECTION") { - const timeLeft = DateTime.fromSeconds(contest.leadSeniorSelectionMessageSentAt) - .plus({ hours: 72 }) - .diffNow(["days", "hours"]) + const timeLeft = DateTime.fromSeconds(contest.leadSeniorSelectionDate).diffNow(["days", "hours"]) return ( Lead Senior Watson selection in progress @@ -197,9 +259,6 @@ export const AdminContestsListActive = () => { return Waiting on full payment } - if (status === "WAITING_ON_FINALIZE_SUBMISSION") - return Waiting of protocol to finalize submission - if (status === "READY_TO_APPROVE_START") { return Ready to approve start } @@ -259,16 +318,8 @@ export const AdminContestsListActive = () => { ) return ( - + - - - - - CONTESTS @@ -300,6 +351,7 @@ export const AdminContestsListActive = () => { Starts {DateTime.fromSeconds(c.startDate).toFormat("LLLL d - t")} + @@ -316,18 +368,20 @@ export const AdminContestsListActive = () => { + @@ -359,10 +413,22 @@ export const AdminContestsListActive = () => { force={forceActionRowIndex === confirmationModal.contestIndex} /> )} - {createContestModalOpen && setCreateContestModalOpen(false)} />} + {updateContestIndex !== undefined && contests && ( + setUpdateContextIndex(undefined)} + contest={contests[updateContestIndex]} + /> + )} {scopeModal && } + {(reportGeneratedModalVisible === 0 || !!reportGeneratedModalVisible) && contests && ( + + )} ) } diff --git a/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx b/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx index 18c22cb3..893ce39f 100644 --- a/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx +++ b/src/pages/admin/AdminContestsList/AdminContestsListFinished.tsx @@ -128,10 +128,12 @@ export const AdminContestsListFinished = () => { {c.auditReport ? ( - ) : ( + ) : c.finalReportAvailable ? ( + ) : ( + Conditions not met for the final report to be generated )} diff --git a/src/pages/admin/AdminContestsList/ConfirmContestModal.tsx b/src/pages/admin/AdminContestsList/ConfirmContestModal.tsx new file mode 100644 index 00000000..f771a12a --- /dev/null +++ b/src/pages/admin/AdminContestsList/ConfirmContestModal.tsx @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useState } from "react" +import { Button } from "../../../components/Button" +import { Column, Row } from "../../../components/Layout" +import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer" +import Modal, { Props as ModalProps } from "../../../components/Modal/Modal" +import { Text } from "../../../components/Text" +import { Title } from "../../../components/Title" +import { useAdminConfirmContest } from "../../../hooks/api/admin/useAdminConfirmContest" +import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests" +import { ErrorModal } from "../../ContestDetails/ErrorModal" +import { ContestValues, CreateContestForm } from "./CreateContestForm" + +type Props = ModalProps & { + contest: ContestsListItem +} + +export const ConfirmContestModal: React.FC = ({ onClose, contest }) => { + const [formIsDirty, setFormIsDirty] = useState(false) + const [displayModalCloseConfirm, setDisplayModalFormConfirm] = useState(false) + + const { confirmContest, isSuccess, isLoading, error, reset } = useAdminConfirmContest() + + useEffect(() => { + if (isSuccess) onClose?.() + }, [isSuccess, onClose]) + + const handleModalClose = useCallback(() => { + if (formIsDirty) { + setDisplayModalFormConfirm(true) + } else { + onClose && onClose() + } + }, [setDisplayModalFormConfirm, onClose, formIsDirty]) + + const handleModalCloseConfirm = useCallback(() => { + onClose && onClose() + }, [onClose]) + + const handleModalCloseCancel = useCallback(() => { + setDisplayModalFormConfirm(false) + }, []) + + const handleFormSubmit = useCallback( + (values: ContestValues) => { + confirmContest({ + id: contest.id, + ...values.contest, + }) + }, + [contest.id, confirmContest] + ) + return ( + + {displayModalCloseConfirm && ( + + + Unsaved contest + + Are you sure you want to close this form? All unsaved changes will be lost and you will need to start + over. + + + + + + + + )} + + + New contest + + + + {error && reset()} />} + + ) +} diff --git a/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx b/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx index a9150c00..a3602ae7 100644 --- a/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx +++ b/src/pages/admin/AdminContestsList/ContestAnnouncementTweetPreview.tsx @@ -1,4 +1,5 @@ import TweetCard from "react-tweet-card" +import { Column, Row } from "../../../components/Layout" import { useAdminContestTweetPreview } from "../../../hooks/api/admin/useAdminContestTweetPreview" type Props = { @@ -6,17 +7,22 @@ type Props = { } export const ContestAnnouncementTweetPreview: React.FC = ({ contestID }) => { - const { data: tweet } = useAdminContestTweetPreview(contestID) + const { data: tweets } = useAdminContestTweetPreview(contestID) return ( - + + {tweets?.map((tweet) => ( + + ))} + ) } diff --git a/src/pages/admin/AdminContestsList/ContestResetInitialScopeModal.tsx b/src/pages/admin/AdminContestsList/ContestResetInitialScopeModal.tsx new file mode 100644 index 00000000..72a67eaf --- /dev/null +++ b/src/pages/admin/AdminContestsList/ContestResetInitialScopeModal.tsx @@ -0,0 +1,49 @@ +import { useCallback, useEffect } from "react" +import { Button } from "../../../components/Button" +import { Column, Row } from "../../../components/Layout" +import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer" +import Modal, { Props as ModalProps } from "../../../components/Modal/Modal" +import { Text } from "../../../components/Text" +import { Title } from "../../../components/Title" +import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests" +import { useAdminResetScope } from "../../../hooks/api/admin/useAdminResetScope" + +type Props = { + contest: ContestsListItem +} & ModalProps + +export const ContestResetInitialScopeModal: React.FC = ({ onClose, contest }) => { + const { resetScope, isLoading, isSuccess } = useAdminResetScope() + + useEffect(() => { + if (isSuccess) { + onClose?.() + } + }, [isSuccess, onClose]) + + const handleResetScope = useCallback(() => { + resetScope({ + contestID: contest.id, + scopeType: "initial", + }) + }, [resetScope, contest.id]) + + return ( + + + + {contest.title}: Reset initial scope + Are you sure you want to reset the scope for this contest? + + + + + + + + ) +} diff --git a/src/pages/admin/AdminContestsList/ContestScopeModal.tsx b/src/pages/admin/AdminContestsList/ContestScopeModal.tsx index 38f03b6c..ebcb7f84 100644 --- a/src/pages/admin/AdminContestsList/ContestScopeModal.tsx +++ b/src/pages/admin/AdminContestsList/ContestScopeModal.tsx @@ -11,6 +11,9 @@ import { useAdminContestScope } from "../../../hooks/api/admin/useAdminContestSc import { useContest } from "../../../hooks/api/contests" import styles from "./AdminContestsList.module.scss" +import { useAdminResetScope } from "../../../hooks/api/admin/useAdminResetScope" +import { useAdminContest } from "../../../hooks/api/admin/useAdminContest" +import { useAdminResetQA } from "../../../hooks/api/admin/useAdminResetQA" type Props = ModalProps & { contestID: number @@ -19,15 +22,16 @@ type Props = ModalProps & { const COMMENT_TO_SOURCE_MIN = 0.8 export const ContestScopeModal: React.FC = ({ onClose, contestID }) => { - const { data: contest, isLoading: contestIsLoading } = useContest(contestID) + const { data: contest, isLoading: contestIsLoading } = useAdminContest(contestID) const { data: scope, isLoading: scopeIsLoading } = useAdminContestScope(contestID) + const { resetScope, isLoading: resetScopeIsLoading } = useAdminResetScope() + const { resetQA, isLoading: resetQAIsLoading } = useAdminResetQA() - const submittedNSLOC = scope?.reduce((t, s) => t + (s.nSLOC ?? 0), 0) - const expectedNSLOCExceeded = contest && scope && (contest.linesOfCode ?? 0) < (submittedNSLOC ?? 0) + const expectedNSLOCExceeded = contest?.nSLOC && contest.expectedNSLOC && contest.nSLOC > contest.expectedNSLOC return ( - + {contest?.title} {expectedNSLOCExceeded ? ( @@ -37,10 +41,10 @@ export const ContestScopeModal: React.FC = ({ onClose, contestID }) => { Submitted nSLOC is higher than expected - Expected nSLOC: {contest?.linesOfCode} + Expected nSLOC: {contest?.expectedNSLOC} - Submitted nSLOC: {submittedNSLOC} + Submitted nSLOC: {contest.nSLOC} ) : null} @@ -60,7 +64,7 @@ export const ContestScopeModal: React.FC = ({ onClose, contestID }) => { @@ -72,24 +76,26 @@ export const ContestScopeModal: React.FC = ({ onClose, contestID }) => { - - - + {s.commentToSourceRatio ? ( + + + + ) : null}
Contracts: - {s.files.length} + {s.files.filter((f) => f.selected).length}
- - Comments ratio: - {Math.round(s.commentToSourceRatio! * 100)}% - {s.commentToSourceRatio! < COMMENT_TO_SOURCE_MIN ? ( - - - Comments ratio is below 80% - - ) : null} - -
+ + Comments ratio: + {Math.round(s.commentToSourceRatio * 100)}% + {s.commentToSourceRatio < COMMENT_TO_SOURCE_MIN ? ( + + + Comments ratio is below 80% + + ) : null} + +
+
diff --git a/src/pages/admin/AdminContestsList/CreateContestForm.module.scss b/src/pages/admin/AdminContestsList/CreateContestForm.module.scss new file mode 100644 index 00000000..d2f55a35 --- /dev/null +++ b/src/pages/admin/AdminContestsList/CreateContestForm.module.scss @@ -0,0 +1,13 @@ +@import "../../../styles/variables"; + +.settingsSection { + border: 1px solid darken($primary-purple, 30%); + padding: 2rem; + width: 100%; + // display: flex; + // flex-direction: column; + + input { + width: 100%; + } +} diff --git a/src/pages/admin/AdminContestsList/CreateContestForm.tsx b/src/pages/admin/AdminContestsList/CreateContestForm.tsx new file mode 100644 index 00000000..d937f290 --- /dev/null +++ b/src/pages/admin/AdminContestsList/CreateContestForm.tsx @@ -0,0 +1,794 @@ +import { useCallback, useEffect, useMemo, useState } from "react" +import { FaChrome, FaTwitter } from "react-icons/fa" +import { useDebounce } from "use-debounce" +import { BigNumber, ethers } from "ethers" +import { DateTime } from "luxon" + +import { Input } from "../../../components/Input" +import { Column, Row } from "../../../components/Layout" +import { Title } from "../../../components/Title" +import { useAdminProtocol } from "../../../hooks/api/admin/useAdminProtocol" +import { Field } from "../../Claim/Field" +import { useAdminTwitterAccount } from "../../../hooks/api/admin/useTwitterAccount" +import { useAdminContestVariables } from "../../../hooks/api/admin/useAdminContestVariables" +import { commify } from "../../../utils/units" +import { Text } from "../../../components/Text" +import TokenInput from "../../../components/TokenInput/TokenInput" +import { Button } from "../../../components/Button" +import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests" +import RadioButton from "../../../components/RadioButton/RadioButton" +import styles from "./CreateContestForm.module.scss" +import { useAdminProtocolContests } from "../../../hooks/api/admin/useAdminProtocolContests" +import Select from "../../../components/Select/Select" +import { useAdminPricing } from "../../../hooks/api/admin/useAdminPricing" + +export type ContestValues = { + protocol: { + id?: number + name?: string + twitter?: string + website?: string + logoUrl?: string + } + contest: { + title: string + shortDescription: string + nSLOC: string + startDate: DateTime + endDate: DateTime + auditRewards: number + judgingPrizePool: number + leadJudgeFixedPay: number + fullPayment: number + lswPaymentStructure?: "TIERED" | "BEST_EFFORTS" | "FIXED" + customLswFixedPay?: number | null + private?: boolean + requiresKYC?: boolean + maxNumberOfParticipants?: number | null + previousContestId?: number | null + token: string + exchangeRate: number + } +} + +type Props = { + onSubmit: (values: ContestValues) => void + onDirtyChange: (dirty: boolean) => void + submitLabel: string + contest?: ContestsListItem + draft?: boolean +} + +const DATE_FORMAT = "yyyy-MM-dd" + +export const CreateContestForm: React.FC = ({ + onSubmit, + onDirtyChange, + submitLabel, + contest, + draft = false, +}) => { + const [protocolName, setProtocolName] = useState("") + const [debouncedProtocolName] = useDebounce(protocolName, 300) + + const { + data: protocol, + isError: protocolNotFound, + isLoading: protocolLoading, + } = useAdminProtocol(debouncedProtocolName) + const { data: protocolContests } = useAdminProtocolContests(protocol?.id) + const [isUpdateContest, setIsUpdateContest] = useState(false) + const [previousContest, setPreviousContest] = useState() + + useEffect(() => { + if (!!previousContest) { + return + } + + if (protocolContests && protocolContests?.length > 0) { + setPreviousContest(protocolContests[0].id) + } + }, [protocolContests, previousContest]) + + const previousContestOptions = useMemo( + () => + protocolContests?.map((item) => ({ + label: item.title, + value: item.id, + })) ?? [], + [protocolContests] + ) + + console.log("Protocol contests", protocolContests) + + const [protocolTwitter, setProtocolTwitter] = useState(protocol?.twitter ?? "") + const [protocolWebsite, setProtocolWebsite] = useState(protocol?.website ?? "") + const [protocolLogoURL, setProtocolLogoURL] = useState(protocol?.logoURL ?? "") + + const [debouncedProtocolTwitter] = useDebounce(protocolTwitter, 300) + const { data: twitterAccount } = useAdminTwitterAccount(debouncedProtocolTwitter) + + const [contestTitle, setContestTitle] = useState("") + const [contestShortDescription, setShortDescription] = useState("") + const [contestNSLOC, setContestNSLOC] = useState(contest?.nSLOC?.toString() ?? "") + const [debouncedContestNSLOC] = useDebounce(contestNSLOC, 300) + const [contestStartDate, setContestStartDate] = useState("") + const [contestAuditLength, setContestAuditLength] = useState("") + const [contestTotalRewards, setContestTotalRewards] = useState(BigNumber.from(0)) + const [contestAuditRewards, setContestAuditRewards] = useState(BigNumber.from(0)) + const [contestJudgingPrizePool, setContestJudgingPrizePool] = useState(BigNumber.from(0)) + const [contestLeadJudgeFixedPay, setContestLeadJudgeFixedPay] = useState(BigNumber.from(0)) + const [contestTotalCost, setContestTotalCost] = useState(BigNumber.from(0)) + const [isPrivate, setIsPrivate] = useState(false) + const [requiresKYC, setRequiresKYC] = useState(false) + const [lswPaymentStructure, setLswPaymentStructure] = useState<"TIERED" | "BEST_EFFORTS" | "FIXED">("TIERED") + const [customLswFixedPay, setCustomLswFixedPay] = useState(undefined) + const [hasLimitedContestants, setHasLimitedContestants] = useState(false) + const [maxNumberOfParticipants, setmaxNumberOfParticipants] = useState("") + + const [initialTotalRewards, setInitialTotalRewards] = useState(BigNumber.from(0)) + const [initialAuditContestRewards, setInitialAuditContestRewards] = useState(BigNumber.from(0)) + const [initialJudgingPrizePool, setInitialJudgingPrizePool] = useState(BigNumber.from(0)) + const [initialLeadJudgeFixedPay, setInitialLeadJudgeFixedPay] = useState(BigNumber.from(0)) + const [initialTotalCost, setInitialTotalCost] = useState(BigNumber.from(0)) + const [initialCustomLswFixedPay, setInitialCustomLswFixedPay] = useState(BigNumber.from(0)) + + const [startDateError, setStartDateError] = useState() + const [shortDescriptionError, setShortDescriptionError] = useState() + + const displayProtocolInfo = !!protocol || protocolNotFound || protocolLoading + + const [token, setToken] = useState(contest?.token ?? "USDC") + + const { data: contestVariables, isSuccess: adminPricingSuccess } = useAdminPricing( + parseInt(debouncedContestNSLOC), + token + ) + + useEffect(() => { + if (contest) { + const startDate = DateTime.fromSeconds(contest.startDate) + const endDate = DateTime.fromSeconds(contest.endDate) + const auditLength = endDate.diff(startDate, "days").days + + setContestTitle(contest.title) + setShortDescription(contest.shortDescription) + setContestStartDate(startDate.toFormat(DATE_FORMAT)) + setContestAuditLength(auditLength.toString()) + setInitialTotalRewards( + ethers.utils.parseUnits(`${contest.rewards + contest.judgingPrizePool + contest.leadJudgeFixedPay}`, 6) + ) + setInitialAuditContestRewards(ethers.utils.parseUnits(`${contest.rewards}`, 6)) + setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contest.judgingPrizePool}`, 6)) + setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contest.leadJudgeFixedPay}`, 6)) + setInitialTotalCost(ethers.utils.parseUnits(`${contest.fullPayment}`, 6)) + setIsPrivate(contest.private) + setRequiresKYC(contest.requiresKYC) + setLswPaymentStructure(contest.lswPaymentStructure) + setHasLimitedContestants(!!contest.maxNumberOfParticipants) + setmaxNumberOfParticipants(contest.maxNumberOfParticipants?.toString() ?? "") + setCustomLswFixedPay(ethers.utils.parseUnits(`${contest.leadSeniorAuditorFixedPay ?? 0}`, 6)) + setInitialCustomLswFixedPay(ethers.utils.parseUnits(`${contest.leadSeniorAuditorFixedPay ?? 0}`, 6)) + } + }, [contest]) + + useEffect(() => { + if (adminPricingSuccess && (!contest || contest.status === "DRAFT")) { + setContestAuditLength(`${contestVariables.length}`) + setInitialTotalRewards(ethers.utils.parseUnits(`${contestVariables.minTotalRewards}`, 6)) + setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables.minContestRewards}`, 6)) + setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables.minJudgingPrizePool}`, 6)) + setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables.minLeadJudgeFixedPay}`, 6)) + setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables.minTotalPrice}`, 6)) + } + }, [adminPricingSuccess, setContestAuditLength, contestVariables, contest]) + + useEffect(() => { + const diff = contestTotalRewards + ?.sub(contestAuditRewards ?? BigNumber.from(0)) + .sub(contestJudgingPrizePool ?? BigNumber.from(0)) + .sub(contestLeadJudgeFixedPay ?? BigNumber.from(0)) + + setInitialAuditContestRewards(contestAuditRewards?.add(diff ?? BigNumber.from(0))) + }, [contestTotalRewards, setInitialAuditContestRewards]) + + useEffect(() => { + if (protocol?.name) { + setProtocolName(protocol?.name) + } + }, [protocol]) + + useEffect(() => { + if (twitterAccount?.profilePictureUrl) { + setProtocolLogoURL(twitterAccount.profilePictureUrl) + } else { + setProtocolLogoURL("") + } + }, [twitterAccount]) + + useEffect(() => { + if (contestStartDate === "") { + setStartDateError(undefined) + return + } + + const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT) + + if (!startDate.isValid) { + setStartDateError(`Invalid date. Must be format ${DATE_FORMAT}`) + return + } + + if (startDate < DateTime.now()) { + setStartDateError("Start date cannot be in the past.") + return + } + + setStartDateError(undefined) + }, [contestStartDate, contestAuditLength]) + + const sherlockFee = useMemo(() => { + const fee = contestTotalCost + ?.sub(contestAuditRewards ?? 0) + .sub(contestJudgingPrizePool ?? 0) + .sub(contestLeadJudgeFixedPay ?? 0) + + return commify(parseInt(ethers.utils.formatUnits(fee ?? 0, 6))) + }, [contestTotalCost, contestAuditRewards, contestJudgingPrizePool, contestLeadJudgeFixedPay]) + + const canSubmit = useMemo(() => { + if (!contest) { + if (protocolName === "") return false + if (protocolLogoURL === "" && !protocol?.logoURL) return false + if (protocolWebsite === "" && !protocol?.website) return false + } + + if (contestTitle === "") return false + + if (draft) return true + + if (contestShortDescription === "") return false + if (contestAuditLength === "") return false + + const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT) + + if (!startDate.isValid) return false + if (startDate < DateTime.now()) return false + if (contestAuditRewards?.eq(BigNumber.from(0))) return false + if (contestTotalCost?.eq(BigNumber.from(0))) return false + if (lswPaymentStructure === "FIXED" && !customLswFixedPay?.gt(BigNumber.from(0))) return false + if (hasLimitedContestants && (maxNumberOfParticipants === "" || maxNumberOfParticipants === "0")) return false + + return true + }, [ + draft, + contestShortDescription, + contestAuditLength, + contestAuditRewards, + contestStartDate, + contestTitle, + contestTotalCost, + protocol?.logoURL, + protocol?.website, + protocolLogoURL, + protocolName, + protocolWebsite, + contest, + lswPaymentStructure, + customLswFixedPay, + hasLimitedContestants, + maxNumberOfParticipants, + ]) + + useEffect(() => { + if (contest) { + const startDate = DateTime.fromSeconds(contest.startDate) + const endDate = DateTime.fromSeconds(contest.endDate) + const auditLength = endDate.diff(startDate, "days").days + + onDirtyChange( + (contestTitle !== contest.title || + contestShortDescription !== contest.shortDescription || + contestNSLOC !== contest.nSLOC?.toString() || + contestStartDate !== startDate.toFormat(DATE_FORMAT) || + contestAuditLength !== auditLength.toString() || + !contestAuditRewards?.eq(ethers.utils.parseUnits(`${contest.rewards}`, 6)) || + !contestJudgingPrizePool?.eq(ethers.utils.parseUnits(`${contest.judgingPrizePool}`, 6)) || + !contestLeadJudgeFixedPay?.eq(ethers.utils.parseUnits(`${contest.leadJudgeFixedPay}`, 6)) || + !contestTotalCost?.eq(ethers.utils.parseUnits(`${contest.fullPayment}`, 6))) ?? + false + ) + } else { + onDirtyChange( + (protocolName !== "" || + contestTitle !== "" || + contestShortDescription !== "" || + contestNSLOC !== "" || + contestStartDate !== "" || + contestAuditLength !== "" || + contestAuditRewards?.gt(BigNumber.from(0)) || + contestJudgingPrizePool?.gt(BigNumber.from(0)) || + contestLeadJudgeFixedPay?.gt(BigNumber.from(0)) || + contestTotalCost?.gt(BigNumber.from(0))) ?? + false + ) + } + }, [ + contest, + contestAuditLength, + contestAuditRewards, + contestJudgingPrizePool, + contestLeadJudgeFixedPay, + contestNSLOC, + contestShortDescription, + contestStartDate, + contestTitle, + contestTotalCost, + onDirtyChange, + protocolName, + ]) + + const handleSubmit = useCallback(() => { + const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT, { zone: "utc" }).set({ + hour: 15, + minute: 0, + second: 0, + millisecond: 0, + }) + const endDate = startDate + .plus({ hours: 24 * parseInt(contestAuditLength) }) + .set({ hour: 15, minute: 0, second: 0, millisecond: 0 }) + onSubmit({ + protocol: { + id: protocol?.id, + name: protocolName === "" ? undefined : protocolName, + twitter: protocolTwitter === "" ? undefined : protocolTwitter, + website: protocolWebsite === "" ? undefined : protocolWebsite, + logoUrl: protocolLogoURL === "" ? undefined : protocolLogoURL, + }, + contest: { + title: contestTitle, + shortDescription: contestShortDescription, + nSLOC: contestNSLOC, + startDate, + endDate, + auditRewards: parseInt(ethers.utils.formatUnits(contestAuditRewards ?? 0, 6)), + judgingPrizePool: parseInt(ethers.utils.formatUnits(contestJudgingPrizePool ?? 0, 6)), + leadJudgeFixedPay: parseInt(ethers.utils.formatUnits(contestLeadJudgeFixedPay ?? 0, 6)), + fullPayment: parseInt(ethers.utils.formatUnits(contestTotalCost ?? 0, 6)), + lswPaymentStructure, + private: isPrivate, + requiresKYC, + customLswFixedPay: customLswFixedPay ? parseInt(ethers.utils.formatUnits(customLswFixedPay ?? 0, 6)) : null, + maxNumberOfParticipants: + hasLimitedContestants && maxNumberOfParticipants && maxNumberOfParticipants !== "" + ? parseInt(maxNumberOfParticipants) + : null, + previousContestId: isUpdateContest ? previousContest : null, + token, + exchangeRate: contestVariables?.exchangeRate ?? 1, + }, + }) + }, [ + contestAuditLength, + contestAuditRewards, + contestJudgingPrizePool, + contestLeadJudgeFixedPay, + contestNSLOC, + contestShortDescription, + contestStartDate, + contestTitle, + contestTotalCost, + onSubmit, + protocol?.id, + protocolLogoURL, + protocolName, + protocolTwitter, + protocolWebsite, + lswPaymentStructure, + isPrivate, + requiresKYC, + customLswFixedPay, + maxNumberOfParticipants, + hasLimitedContestants, + previousContest, + isUpdateContest, + token, + contestVariables?.exchangeRate, + ]) + + const isMinimum = useMemo( + () => + contestVariables && + contestTotalRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.minTotalRewards}`, 6)) && + contestAuditRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.minContestRewards}`, 6)) && + contestTotalCost?.eq(ethers.utils.parseUnits(`${contestVariables?.minTotalPrice}`, 6)), + [contestTotalRewards, contestAuditRewards, contestTotalCost, contestVariables] + ) + + const isRecommended = useMemo( + () => + contestVariables && + contestTotalRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.recTotalRewards}`, 6)) && + contestAuditRewards?.eq(ethers.utils.parseUnits(`${contestVariables?.recContestRewards}`, 6)) && + contestTotalCost?.eq(ethers.utils.parseUnits(`${contestVariables?.recTotalPrice}`, 6)), + [contestTotalRewards, contestAuditRewards, contestTotalCost, contestVariables] + ) + + const handleAutoFillValues = useCallback( + (type: "minimum" | "recommended") => { + if (type === "minimum") { + setInitialTotalRewards(ethers.utils.parseUnits(`${contestVariables?.minTotalRewards}`, 6)) + setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables?.minContestRewards}`, 6)) + setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables?.minTotalPrice}`, 6)) + setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables?.minLeadJudgeFixedPay}`, 6)) + setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables?.minJudgingPrizePool}`, 6)) + } else { + setInitialTotalRewards(ethers.utils.parseUnits(`${contestVariables?.recTotalRewards}`, 6)) + setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables?.recContestRewards}`, 6)) + setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables?.recTotalPrice}`, 6)) + setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables?.recLeadJudgeFixedPay}`, 6)) + setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables?.recJudgingPrizePool}`, 6)) + } + }, + [setInitialTotalCost, setInitialAuditContestRewards, setInitialTotalRewards, contestVariables] + ) + + const lswPaymentStructureDetails = useMemo(() => { + switch (lswPaymentStructure) { + case "TIERED": + return "The Lead Senior Watson fixed pay is determied by his leaderboard ranking." + case "BEST_EFFORTS": + return "The Lead Senior Watson is 33% of the Audit Contest Rewards." + case "FIXED": + return "The Lead Senior Watson is a custom fixed amount." + } + }, [lswPaymentStructure]) + + const handleSetContestSetup = useCallback((contestSetup: "public" | "private" | "1v1" | "custom") => { + if (contestSetup === "public") { + setIsPrivate(false) + setRequiresKYC(false) + setLswPaymentStructure("TIERED") + setHasLimitedContestants(false) + } else if (contestSetup === "private") { + setIsPrivate(true) + setRequiresKYC(true) + setLswPaymentStructure("TIERED") + setHasLimitedContestants(true) + setmaxNumberOfParticipants("10") + } else if (contestSetup === "1v1") { + setIsPrivate(true) + setRequiresKYC(true) + setLswPaymentStructure("FIXED") + setHasLimitedContestants(true) + setmaxNumberOfParticipants("2") + } + }, []) + + const contestSetup = useMemo(() => { + if (!isPrivate && !requiresKYC && lswPaymentStructure === "TIERED" && !hasLimitedContestants) { + return "public" + } else if ( + isPrivate && + requiresKYC && + lswPaymentStructure === "TIERED" && + hasLimitedContestants && + maxNumberOfParticipants === "10" + ) { + return "private" + } else if ( + isPrivate && + requiresKYC && + lswPaymentStructure === "FIXED" && + hasLimitedContestants && + maxNumberOfParticipants === "2" + ) { + return "1v1" + } else { + return "custom" + } + }, [isPrivate, requiresKYC, lswPaymentStructure, hasLimitedContestants, maxNumberOfParticipants]) + + return ( + + {!contest ? ( + <> + + PROTOCOL + + + + {displayProtocolInfo && ( + <> + + + Twitter +
+ } + > + + + + + Website +
+ } + > + + + + + + {(protocol?.logoURL || protocolLogoURL) && ( + logo preview + )} + + + + )} +
+
+ + ) : null} + + CONTEST + + + + {draft && !!previousContest && ( + + + {isUpdateContest && ( + + + + + + + + + + + + + + + + + + + + + Pricing presets + + {isMinimum ? ( + + Using minimum pricing + + ) : null} + {isRecommended ? ( + + Using recommended pricing + + ) : null} + + + + + + + + + + + + + + + + + + + + + {`Admin Fee: ${sherlockFee} ${token}`} + + + +
+ + + + + + {lswPaymentStructureDetails} + + {lswPaymentStructure === "FIXED" && ( + + )} + + + + + {hasLimitedContestants && ( + <> + + + Includes the Lead Senior Watson + + + )} + +
+
+
+ )} +
+ +
+ ) +} diff --git a/src/pages/admin/AdminContestsList/CreateContestModal.tsx b/src/pages/admin/AdminContestsList/CreateContestModal.tsx index 9ab7ac16..047a6b50 100644 --- a/src/pages/admin/AdminContestsList/CreateContestModal.tsx +++ b/src/pages/admin/AdminContestsList/CreateContestModal.tsx @@ -1,267 +1,26 @@ -import { BigNumber, ethers } from "ethers" -import { DateTime } from "luxon" -import { useCallback, useEffect, useMemo, useState } from "react" -import { FaChrome, FaTwitter, FaGithub } from "react-icons/fa" -import { useDebounce } from "use-debounce" +import { useCallback, useEffect, useState } from "react" import { Button } from "../../../components/Button" -import { Input } from "../../../components/Input" import { Column, Row } from "../../../components/Layout" import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer" import Modal, { Props as ModalProps } from "../../../components/Modal/Modal" import { Text } from "../../../components/Text" import { Title } from "../../../components/Title" -import TokenInput from "../../../components/TokenInput/TokenInput" import { useAdminCreateContest } from "../../../hooks/api/admin/useAdminCreateContest" -import { useAdminProtocol } from "../../../hooks/api/admin/useAdminProtocol" -import { commify } from "../../../utils/units" -import { Field } from "../../Claim/Field" import { ErrorModal } from "../../../pages/ContestDetails/ErrorModal" -import { useAdminTwitterAccount } from "../../../hooks/api/admin/useTwitterAccount" -import { useAdminContestVariables } from "../../../hooks/api/admin/useAdminContestVariables" +import { ContestValues, CreateContestForm } from "./CreateContestForm" type Props = ModalProps & {} -const DATE_FORMAT = "yyyy-MM-dd" - export const CreateContestModal: React.FC = ({ onClose }) => { - const [protocolName, setProtocolName] = useState("") - const [debouncedProtocolName] = useDebounce(protocolName, 300) - const { - data: protocol, - isError: protocolNotFound, - isLoading: protocolLoading, - } = useAdminProtocol(debouncedProtocolName) + const [formIsDirty, setFormIsDirty] = useState(false) const { createContest, isLoading, isSuccess, error, reset } = useAdminCreateContest() - const [protocolTwitter, setProtocolTwitter] = useState(protocol?.twitter ?? "") - const [protocolGithubTeam, setProtocolGithubTeam] = useState(protocol?.githubTeam ?? "") - const [protocolWebsite, setProtocolWebsite] = useState(protocol?.website ?? "") - const [protocolLogoURL, setProtocolLogoURL] = useState(protocol?.logoURL ?? "") - - const [debouncedProtocolTwitter] = useDebounce(protocolTwitter, 300) - const { data: twitterAccount } = useAdminTwitterAccount(debouncedProtocolTwitter) - - const [contestTitle, setContestTitle] = useState("") - const [contestShortDescription, setShortDescription] = useState("") - const [contestNSLOC, setContestNSLOC] = useState("") - const [debouncedContestNSLOC] = useDebounce(contestNSLOC, 300) - const [contestStartDate, setContestStartDate] = useState("") - const [contestAuditLength, setContestAuditLength] = useState("") - const [contestAuditRewards, setContestAuditRewards] = useState(BigNumber.from(0)) - const [contestJudgingPrizePool, setContestJudgingPrizePool] = useState(BigNumber.from(0)) - const [contestLeadJudgeFixedPay, setContestLeadJudgeFixedPay] = useState(BigNumber.from(0)) - const [contestTotalCost, setContestTotalCost] = useState(BigNumber.from(0)) - const [initialAuditContestRewards, setInitialAuditContestRewards] = useState(BigNumber.from(0)) - const [initialJudgingPrizePool, setInitialJudgingPrizePool] = useState(BigNumber.from(0)) - const [initialLeadJudgeFixedPay, setInitialLeadJudgeFixedPay] = useState(BigNumber.from(0)) - const [initialTotalCost, setInitialTotalCost] = useState(BigNumber.from(0)) - - const [startDateError, setStartDateError] = useState() - const [shortDescriptionError, setShortDescriptionError] = useState() - const [displayModalCloseConfirm, setDisplayModalFormConfirm] = useState(false) - const displayProtocolInfo = !!protocol || protocolNotFound || protocolLoading - - const { data: contestVariables, isSuccess: contestVariablesSuccess } = useAdminContestVariables( - parseInt(debouncedContestNSLOC) - ) - - useEffect(() => { - if (contestVariablesSuccess) { - setContestAuditLength(`${contestVariables.length}`) - - setInitialAuditContestRewards(ethers.utils.parseUnits(`${contestVariables.auditContestRewards}`, 6)) - setInitialJudgingPrizePool(ethers.utils.parseUnits(`${contestVariables.judgingPrizePool}`, 6)) - setInitialLeadJudgeFixedPay(ethers.utils.parseUnits(`${contestVariables.leadJudgeFixedPay}`, 6)) - setInitialTotalCost(ethers.utils.parseUnits(`${contestVariables.fullPayment}`, 6)) - } else { - setContestAuditLength("") - setInitialAuditContestRewards(BigNumber.from(0)) - setInitialJudgingPrizePool(BigNumber.from(0)) - setInitialLeadJudgeFixedPay(BigNumber.from(0)) - setInitialTotalCost(BigNumber.from(0)) - } - }, [contestVariablesSuccess, setContestAuditLength, contestVariables]) - useEffect(() => { if (isSuccess) onClose?.() }, [isSuccess, onClose]) - useEffect(() => { - if (protocol?.name) { - setProtocolName(protocol?.name) - } - }, [protocol]) - - useEffect(() => { - if (twitterAccount?.profilePictureUrl) { - setProtocolLogoURL(twitterAccount.profilePictureUrl) - } else { - setProtocolLogoURL("") - } - }, [twitterAccount]) - - useEffect(() => { - if (contestStartDate === "") { - setStartDateError(undefined) - return - } - - const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT) - - if (!startDate.isValid) { - setStartDateError(`Invalid date. Must be format ${DATE_FORMAT}`) - return - } - - if (startDate < DateTime.now()) { - setStartDateError("Start date cannot be in the past.") - return - } - - setStartDateError(undefined) - }, [contestStartDate, contestAuditLength]) - - const sherlockFee = useMemo(() => { - const fee = contestTotalCost - ?.sub(contestAuditRewards ?? 0) - .sub(contestJudgingPrizePool ?? 0) - .sub(contestLeadJudgeFixedPay ?? 0) - - return commify(parseInt(ethers.utils.formatUnits(fee ?? 0, 6))) - }, [contestTotalCost, contestAuditRewards, contestJudgingPrizePool, contestLeadJudgeFixedPay]) - - const canCreateContest = useMemo(() => { - if (protocolName === "") return false - if (protocolLogoURL === "" && !protocol?.logoURL) return false - if (protocolWebsite === "" && !protocol?.website) return false - if (protocolTwitter === "" && !protocol?.twitter) return false - if (protocolGithubTeam === "" && !protocol?.githubTeam) return false - - if (contestTitle === "") return false - if (contestShortDescription.length < 100 || contestShortDescription.length > 200) return false - - if (contestAuditLength === "") return false - - const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT) - - if (!startDate.isValid) return false - if (startDate < DateTime.now()) return false - - if (contestAuditRewards?.eq(BigNumber.from(0))) return false - if (contestTotalCost?.eq(BigNumber.from(0))) return false - - return true - }, [ - contestAuditLength, - contestAuditRewards, - contestShortDescription.length, - contestStartDate, - contestTitle, - contestTotalCost, - protocol?.logoURL, - protocol?.twitter, - protocol?.website, - protocol?.githubTeam, - protocolLogoURL, - protocolName, - protocolTwitter, - protocolWebsite, - protocolGithubTeam, - ]) - - const handleUpdateShortDescription = useCallback((value: string) => { - setShortDescription(value) - - if (value === "") { - setShortDescriptionError(undefined) - } else if (value.length < 100) { - setShortDescriptionError("Too short. Must be between 100 and 200 characters.") - } else if (value.length > 200) { - setShortDescriptionError("Too long. Must be between 100 and 200 characters.") - } else { - setShortDescriptionError(undefined) - } - }, []) - - const handleCreateContest = useCallback(() => { - const startDate = DateTime.fromFormat(contestStartDate, DATE_FORMAT, { zone: "utc" }).set({ - hour: 15, - minute: 0, - second: 0, - millisecond: 0, - }) - const endDate = startDate - .plus({ hours: 24 * parseInt(contestAuditLength) }) - .set({ hour: 15, minute: 0, second: 0, millisecond: 0 }) - - createContest({ - protocol: { - id: protocol?.id, - name: protocolName, - githubTeam: protocolGithubTeam || undefined, - website: protocolWebsite || undefined, - twitter: protocolTwitter || undefined, - logoUrl: protocolLogoURL || undefined, - }, - contest: { - title: contestTitle, - shortDescription: contestShortDescription, - nSLOC: contestNSLOC, - startDate, - endDate, - auditRewards: parseInt(ethers.utils.formatUnits(contestAuditRewards ?? 0, 6)), - judgingPrizePool: parseInt(ethers.utils.formatUnits(contestJudgingPrizePool ?? 0, 6)), - leadJudgeFixedPay: parseInt(ethers.utils.formatUnits(contestLeadJudgeFixedPay ?? 0, 6)), - fullPayment: parseInt(ethers.utils.formatUnits(contestTotalCost ?? 0, 6)), - }, - }) - }, [ - contestAuditLength, - contestAuditRewards, - contestJudgingPrizePool, - contestLeadJudgeFixedPay, - contestNSLOC, - contestShortDescription, - contestStartDate, - contestTitle, - contestTotalCost, - createContest, - protocol?.id, - protocolLogoURL, - protocolName, - protocolTwitter, - protocolWebsite, - protocolGithubTeam, - ]) - - const formIsDirty = useMemo( - () => - protocolName !== "" || - contestTitle !== "" || - contestShortDescription !== "" || - contestNSLOC !== "" || - contestStartDate !== "" || - contestAuditLength !== "" || - contestAuditRewards?.gt(BigNumber.from(0)) || - contestJudgingPrizePool?.gt(BigNumber.from(0)) || - contestLeadJudgeFixedPay?.gt(BigNumber.from(0)) || - contestTotalCost?.gt(BigNumber.from(0)), - [ - contestAuditLength, - contestAuditRewards, - contestJudgingPrizePool, - contestLeadJudgeFixedPay, - contestNSLOC, - contestShortDescription, - contestStartDate, - contestTitle, - contestTotalCost, - protocolName, - ] - ) - const handleModalClose = useCallback(() => { if (formIsDirty) { setDisplayModalFormConfirm(true) @@ -297,123 +56,15 @@ export const CreateContestModal: React.FC = ({ onClose }) => {
)} - + New contest - - PROTOCOL - - - - {displayProtocolInfo && ( - <> - - - GitHub - - } - > - - - - - Twitter - - } - > - - - - - Website - - } - > - - - - - - {(protocol?.logoURL || protocolLogoURL) && ( - logo preview - )} - - - - )} - -
- - CONTEST - - - - - - - - - - - - - - - - - - - - - - - - - - - - {`Admin Fee: ${sherlockFee} USDC`} - -
- +
{error && reset()} />} diff --git a/src/pages/admin/AdminContestsList/DeleteDraftContestModal.tsx b/src/pages/admin/AdminContestsList/DeleteDraftContestModal.tsx new file mode 100644 index 00000000..2a5ba09a --- /dev/null +++ b/src/pages/admin/AdminContestsList/DeleteDraftContestModal.tsx @@ -0,0 +1,48 @@ +import { useCallback, useEffect } from "react" +import { Button } from "../../../components/Button" +import { Column, Row } from "../../../components/Layout" +import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer" +import Modal, { Props as ModalProps } from "../../../components/Modal/Modal" +import { Text } from "../../../components/Text" +import { Title } from "../../../components/Title" +import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests" +import { useAdminDeleteDraftContest } from "../../../hooks/api/admin/useAdminDeleteDraftContest" + +type Props = { + contest: ContestsListItem +} & ModalProps + +export const DeleteDraftContestModal: React.FC = ({ onClose, contest }) => { + const { deleteContest, isLoading, isSuccess } = useAdminDeleteDraftContest() + + useEffect(() => { + if (isSuccess) { + onClose?.() + } + }, [isSuccess, onClose]) + + const handleResetScope = useCallback(() => { + deleteContest({ + contestID: contest?.id, + }) + }, [deleteContest, contest]) + + return ( + + + + {contest?.title}: Delete contest + Are you sure you want to delete this draft contest? + + + + + + + + ) +} diff --git a/src/pages/admin/AdminContestsList/TelegramBotIndicator.tsx b/src/pages/admin/AdminContestsList/TelegramBotIndicator.tsx new file mode 100644 index 00000000..04178ef6 --- /dev/null +++ b/src/pages/admin/AdminContestsList/TelegramBotIndicator.tsx @@ -0,0 +1,39 @@ +import { FaTelegram } from "react-icons/fa" +import { Column } from "../../../components/Layout" +import { Text } from "../../../components/Text" +import { Tooltip, TooltipTrigger, TooltipContent } from "../../../components/Tooltip/Tooltip" +import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests" + +import styles from "./AdminContestsList.module.scss" + +type Props = { + contest: Pick +} + +export const TelegramBotIndicator: React.FC = ({ contest }) => { + return contest.telegramChat ? ( + + Linked to telegram group + + ) : ( + + + + Telegram bot not added + + + + {contest.telegramChat ? null : ( + + + To link this contest with a Telegram group chat: + + 1. add https://t.me/sherlockdefibot to the protocol's telegram chat + 2. send a link to the dashboard in the chat + After that, it will be automatically linked. + + )} + + + ) +} diff --git a/src/pages/admin/AdminContestsList/UpdateContestModal.tsx b/src/pages/admin/AdminContestsList/UpdateContestModal.tsx new file mode 100644 index 00000000..8f1dd151 --- /dev/null +++ b/src/pages/admin/AdminContestsList/UpdateContestModal.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from "react" +import { Button } from "../../../components/Button" +import { Column, Row } from "../../../components/Layout" +import LoadingContainer from "../../../components/LoadingContainer/LoadingContainer" +import Modal, { Props as ModalProps } from "../../../components/Modal/Modal" +import { Text } from "../../../components/Text" +import { Title } from "../../../components/Title" +import { ErrorModal } from "../../../pages/ContestDetails/ErrorModal" +import { ContestValues, CreateContestForm } from "./CreateContestForm" +import { ContestsListItem } from "../../../hooks/api/admin/useAdminContests" +import { useAdminUpdateContest } from "../../../hooks/api/admin/useAdminUpdateContest" + +type Props = ModalProps & { + contest: ContestsListItem +} + +export const UpdateContestModal: React.FC = ({ onClose, contest }) => { + const [formIsDirty, setFormIsDirty] = useState(false) + const { updateContest, isLoading, isSuccess, error, reset } = useAdminUpdateContest() + + const [displayModalCloseConfirm, setDisplayModalFormConfirm] = useState(false) + + useEffect(() => { + if (isSuccess) onClose?.() + }, [isSuccess, onClose]) + + const handleModalClose = useCallback(() => { + if (formIsDirty) { + setDisplayModalFormConfirm(true) + } else { + onClose && onClose() + } + }, [setDisplayModalFormConfirm, onClose, formIsDirty]) + + const handleModalCloseConfirm = useCallback(() => { + onClose && onClose() + }, [onClose]) + + const handleModalCloseCancel = useCallback(() => { + setDisplayModalFormConfirm(false) + }, []) + + const handleFormSubmit = useCallback( + (values: ContestValues) => { + updateContest({ + id: contest.id, + ...values.contest, + }) + }, + [contest.id, updateContest] + ) + + return ( + + {displayModalCloseConfirm && ( + + + Unsaved contest + + Are you sure you want to close this form? All unsaved changes will be lost and you will need to start + over. + + + + + + + + )} + + + Edit {contest.title} + + + + {error && reset()} />} + + ) +} diff --git a/src/pages/admin/AdminScope/AdminScope.tsx b/src/pages/admin/AdminScope/AdminScope.tsx index 64762eec..1659493a 100644 --- a/src/pages/admin/AdminScope/AdminScope.tsx +++ b/src/pages/admin/AdminScope/AdminScope.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react" -import { FaCopy, FaDownload, FaGithub } from "react-icons/fa" +import { FaGithub } from "react-icons/fa" import { useDebounce } from "use-debounce" import { Box } from "../../../components/Box" import { Button } from "../../../components/Button" @@ -14,6 +14,10 @@ import { shortenCommitHash } from "../../../utils/repository" import { BranchSelectionModal } from "../../AuditScope/BranchSelectionModal" import { CommitSelectionModal } from "../../AuditScope/CommitSelectionModal" import { RepositoryContractsSelector } from "../../AuditScope/RepositoryContractsSelector" +import { repositoryContractsQuery, useRepositoryContracts } from "../../../hooks/api/scope/useRepositoryContracts" +import { SaveScopeSuccessModal } from "./SaveScopeSuccessModal" +import { ScopeErrorModal } from "./ScopeErrorModal" +import { useQueryClient } from "react-query" export const AdminScope = () => { const [repoLink, setRepoLink] = useState("") @@ -21,13 +25,22 @@ export const AdminScope = () => { const [debouncedRepoName] = useDebounce(repoName, 300) const [branchName, setBranchName] = useState() const [commitHash, setCommitHash] = useState() + const [nSLOCAdjustment, setNSLOCAdjustment] = useState() const [files, setFiles] = useState([]) const [branchSelectionModalOpen, setBranchSelectionModalOpen] = useState(false) const [commitSelectionModalOpen, setCommitSelectionModalOpen] = useState(false) const { data: repo, isLoading: repoIsLoading } = useRepository(debouncedRepoName) - const { submitScope, isLoading, data: report } = useAdminSubmitScope() + const { submitScope, isLoading, data: report, isSuccess } = useAdminSubmitScope() + + const { + data: repoContracts, + isLoading: isLoadingContracts, + error: contractsError, + } = useRepositoryContracts(debouncedRepoName, commitHash ?? "") + + const queryClient = useQueryClient() useEffect(() => { const pattern = /^https?:\/\/github\.com\/([A-Za-z0-9-]+\/[A-Za-z0-9-]+)(?:\.git)?(?:\/tree\/([A-Za-z0-9-]+))?$/ @@ -46,6 +59,10 @@ export const AdminScope = () => { } }, [repo, branchName]) + const handleErrorModalClose = useCallback(() => { + window.location.reload() + }, []) + const handlePathSelected = useCallback( (selectedPaths: string[]) => { setFiles((f) => { @@ -75,12 +92,9 @@ export const AdminScope = () => { [setCommitHash] ) - const handleSelectAll = useCallback( - (selectedPaths: string[]) => { - setFiles(selectedPaths) - }, - [setFiles] - ) + const handleSelectAll = useCallback(() => { + setFiles(repoContracts?.rawPaths ?? []) + }, [repoContracts]) const handleClearSelection = useCallback(() => { setFiles([]) @@ -99,21 +113,15 @@ export const AdminScope = () => { branchName: branchName!, commitHash: commitHash!, files, + nSLOCAdjustment, }) - }, [canGenerateReport, submitScope, repo, branchName, commitHash, files]) - - const handleDownloadReport = useCallback(() => { - if (report?.url) { - window.open(report.url, "blank") - } - }, [report]) - - const handleCopyLink = useCallback(async () => { - await navigator.clipboard.writeText(report?.url ?? "") - }, [report?.url]) + }, [canGenerateReport, submitScope, repo, branchName, commitHash, files, nSLOCAdjustment]) return ( - + @@ -142,29 +150,17 @@ export const AdminScope = () => { )} {repo && ( - {report ? ( - - - nSLOC: - {report.nSLOC} - - - - - - - - - ) : ( - - )} + + nSLOC Adjustment + + + + )} + {repo && ( + + )} @@ -177,17 +173,20 @@ export const AdminScope = () => {   {repo.name} - + {repoContracts ? ( + + ) : null}
)} + {isSuccess && } + {contractsError && } {branchSelectionModalOpen && ( = ({ reportURL, ...props }) => { + const handleDownloadClick = useCallback(() => { + window.open(reportURL, "blank") + }, [reportURL]) + + return ( + + + Scope saved + + + + ) +} diff --git a/src/pages/admin/AdminScope/ScopeErrorModal.tsx b/src/pages/admin/AdminScope/ScopeErrorModal.tsx new file mode 100644 index 00000000..9b47a6ca --- /dev/null +++ b/src/pages/admin/AdminScope/ScopeErrorModal.tsx @@ -0,0 +1,21 @@ +import { useCallback } from "react" +import { Button } from "../../../components/Button" +import { Column } from "../../../components/Layout" +import Modal, { Props as ModalProps } from "../../../components/Modal/Modal" +import { Title } from "../../../components/Title" +import { FaDownload } from "react-icons/fa" +import { Text } from "../../../components/Text" + +type Props = ModalProps & {} + +export const ScopeErrorModal: React.FC = (props) => { + return ( + + + Solidity metrics failed + There was an error analyzing the scope. Try again. + + + + ) +} diff --git a/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx b/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx index cd14dadc..4232f568 100644 --- a/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx +++ b/src/pages/protocol_dashboard/ContextQuestions/ContextQuestions.tsx @@ -13,6 +13,7 @@ import { useContextQuestions } from "../../../hooks/api/protocols/useContextQues import { useSubmitContextQuestionsAnswers } from "../../../hooks/api/protocols/useSubmitContextQuestionsAnswers" import { useUpdateContextQuestionAnswers } from "../../../hooks/api/protocols/useUpdateContextQuestionAnswers" import Modal, { Props as ModalProps } from "../../../components/Modal/Modal" +import * as DOMPurify from "dompurify" import styles from "./ContextQuestions.module.scss" import { ErrorModal } from "../../ContestDetails/ErrorModal" @@ -139,11 +140,16 @@ export const ContextQuestions = () => { {contextQuestions && contextQuestions.length > 0 ? ( contextQuestions?.map((q) => { const answer = answers.find((a) => a.questionID === q.id) + const cleanDescriptionHTML = DOMPurify.sanitize(q.description, { + USE_PROFILES: { html: true }, + ADD_ATTR: ["target"], + }) + return ( {q.question} - {q.description} +