diff --git a/packages/examples/sdk-frontend-react/src/app/app.tsx b/packages/examples/sdk-frontend-react/src/app/app.tsx index 98a5bd2c7..e4e7d2303 100644 --- a/packages/examples/sdk-frontend-react/src/app/app.tsx +++ b/packages/examples/sdk-frontend-react/src/app/app.tsx @@ -313,7 +313,7 @@ export function App() { - + (''); const [guildId, setGuildId] = useState(''); const [specificRoleId, setSpecificRoleId] = useState(''); + const [unit, setUnit] = useState('TOKEN'); + const [decimals, setDecimals] = useState(18); const [quantity, setQuantity] = useState<{ value: number; range: number }>({ value: 0, @@ -72,6 +79,7 @@ const AddCriteria = ({ }); const { env } = useChatData(); const theme = useContext(ThemeContext); + const groupInfoToast = useToast(); const isMobile = useMediaQuery(device.mobileL); @@ -160,23 +168,15 @@ const AddCriteria = ({ }, }; - const tokenCategoryValues = [ - { - id: 0, + const dropdownSubCategoryValues: DropdownSubCategoryValuesType = { + ERC20: { value: SUBCATEGORY.HOLDER, title: 'Holder', - function: () => setSelectedSubCategoryValue(0), }, - { - id: 1, + ERC721:{ value: SUBCATEGORY.OWENER, title: 'Owner', - function: () => setSelectedSubCategoryValue(1), }, - ]; - const dropdownSubCategoryValues: DropdownSubCategoryValuesType = { - ERC20: tokenCategoryValues, - ERC721: tokenCategoryValues, INVITE: { value: SUBCATEGORY.DEFAULT, title: 'Default', @@ -308,7 +308,7 @@ const AddCriteria = ({ let subCategory = 'DEFAULT'; if (_type === 'PUSH') { if (category === CATEGORY.ERC20 || category === CATEGORY.ERC721) { - subCategory = tokenCategoryValues[selectedSubCategoryValue].value; + subCategory = category === CATEGORY.ERC20 ? SUBCATEGORY.HOLDER : SUBCATEGORY.OWENER } else if (category === CATEGORY.CustomEndpoint) { subCategory = 'GET'; } @@ -323,7 +323,8 @@ const AddCriteria = ({ contract: `${selectedChain}:${contract}`, amount: quantity.value, comparison: dropdownQuantityRangeValues[quantity.range].value, - decimals: 18, + decimals: category === CATEGORY.ERC20 ? decimals : undefined, + token: unit, }; } else if (category === CATEGORY.INVITE) { const _inviteRoles = []; @@ -364,7 +365,11 @@ const AddCriteria = ({ if (Object.keys(errors).length) { setValidationErrors(errors); } else { - criteriaState.addNewRule(rule); + const isSuccess = criteriaState.addNewRule(rule); + if (!isSuccess) { + showError('Selected Criteria was already added'); + return; + } if (handlePrevious) { handlePrevious(); } @@ -395,12 +400,16 @@ const AddCriteria = ({ oldValue.category === CATEGORY.ERC20 || oldValue.category === CATEGORY.ERC721 ) { - setSelectedSubCategoryValue( - tokenCategoryValues.findIndex( - (obj) => obj.value === oldValue.subcategory - ) - ); + + if(pushData.token){ + setUnit(pushData.token) + } + if (pushData.decimals) { + setDecimals(decimals); + } + + // TODO: make helper function for this const contractAndChain: string[] = ( pushData.contract || 'eip155:1:0x' ).split(':'); @@ -439,6 +448,53 @@ const AddCriteria = ({ } }, []); + const getSeletedType = () => { + return dropdownTypeValues[selectedTypeValue].value || 'PUSH'; + }; + + const getSelectedCategory = () => { + const category: string = + (dropdownCategoryValues['PUSH'] as DropdownValueType[])[ + selectedCategoryValue + ].value || CATEGORY.ERC20; + + return category; + }; + + const getSelectedChain = () => { + return dropdownChainsValues[selectedChainValue].value || 'eip155:1'; + }; + + // Fetch the contract info + useEffect(() => { + // TODO: optimize to reduce this call call when user is typing + (async()=>{ + setValidationErrors(prev => ({...prev, tokenError:undefined})) + + const _type = getSeletedType(); + const _category: string = getSelectedCategory(); + const _chainInfo = getSelectedChain(); + + await tokenFetchHandler( + contract, + _type, + _category, + _chainInfo, + setUnit, + setDecimals + ); + })(); + }, [contract, selectedCategoryValue, selectedChainValue]); + + const showError = (errorMessage: string) => { + groupInfoToast.showMessageToast({ + toastTitle: 'Error', + toastMessage: errorMessage, + toastType: 'ERROR', + getToastIcon: (size) => , + }); + }; + return (
- setContract(e.target.value)} - placeholder="e.g. 0x123..." - /> +
+ setContract(e.target.value)} + placeholder="e.g. 0x123..." + error={!!validationErrors?.tokenError} + /> + {!!validationErrors?.tokenError && ( + {validationErrors?.tokenError} + )} +
+
+ {!!validationErrors?.tokenAmount && ( + {validationErrors?.tokenAmount} + )} +
)} @@ -574,9 +642,7 @@ const AddCriteria = ({ { setGuildComparison(newEl); diff --git a/packages/uiweb/src/lib/components/chat/CreateGroup/ConditionsComponent.tsx b/packages/uiweb/src/lib/components/chat/CreateGroup/ConditionsComponent.tsx index 9ed370c2e..db704210a 100644 --- a/packages/uiweb/src/lib/components/chat/CreateGroup/ConditionsComponent.tsx +++ b/packages/uiweb/src/lib/components/chat/CreateGroup/ConditionsComponent.tsx @@ -8,7 +8,7 @@ import { ThemeContext } from '../theme/ThemeProvider'; import Dropdown, { DropdownValueType } from '../reusables/DropDown'; import { ConditionArray, ConditionData, IChatTheme } from '../exportedTypes'; import { useClickAway } from '../../../hooks'; -import { CATEGORY, CRITERIA_TYPE, CriteriaType, TOKEN_NFT_COMPARISION, TokenNftComparision } from '../types'; +import { CATEGORY, CRITERIA_TYPE, CriteriaType, PushData, TOKEN_NFT_COMPARISION, TokenNftComparision } from '../types'; import EditSvg from '../../../icons/EditSvg.svg'; import RemoveSvg from '../../../icons/RemoveSvg.svg'; @@ -94,11 +94,23 @@ const CriteriaSection = ({ criteria }: { criteria: ConditionData }) => { return (GUILD_COMPARISON_OPTIONS.find(option => option.value === criteria?.data?.['comparison']))?.heading; } + + const getTokenSymbol = (conditionData:ConditionData)=>{ + if(conditionData.data){ + const data:PushData = conditionData.data; + if(data.token){ + return shortenText(data.token, 15) + } + } + + return conditionData.category + } + return (
{ {getTokenNftComparisionLabel()}{' '} {/* need to fetch token symbol */} - {criteria?.data?.['amount']} {criteria.category} + {criteria?.data?.['amount']} {getTokenSymbol(criteria)} )} {criteria.category === CATEGORY.INVITE && ( diff --git a/packages/uiweb/src/lib/components/chat/CreateGroup/CreateGroupModal.tsx b/packages/uiweb/src/lib/components/chat/CreateGroup/CreateGroupModal.tsx index dc9b94e75..9e7bddbb8 100644 --- a/packages/uiweb/src/lib/components/chat/CreateGroup/CreateGroupModal.tsx +++ b/packages/uiweb/src/lib/components/chat/CreateGroup/CreateGroupModal.tsx @@ -70,12 +70,12 @@ export const CreateGroupModal: React.FC = ({ } }, [activeComponent]); - const [groupInputDetails, setGroupInputDetails] = - useState({ - groupName: '', - groupDescription: '', - groupImage: '', - }); + const useDummyGroupInfo = true; + const [groupInputDetails, setGroupInputDetails] = useState({ + groupName: useDummyGroupInfo ? 'This is duumy group name' : '', + groupDescription: useDummyGroupInfo ? 'This is dummy group description for testing' : '', + groupImage: useDummyGroupInfo ? ProfilePicture : '' + }) const renderComponent = () => { switch (activeComponent) { diff --git a/packages/uiweb/src/lib/components/chat/helpers/tokenGatedGroup.ts b/packages/uiweb/src/lib/components/chat/helpers/tokenGatedGroup.ts index 173d859a2..047bc76e8 100644 --- a/packages/uiweb/src/lib/components/chat/helpers/tokenGatedGroup.ts +++ b/packages/uiweb/src/lib/components/chat/helpers/tokenGatedGroup.ts @@ -1,4 +1,7 @@ import axios from 'axios'; +import { ethers } from "ethers"; + +import { fetchERC20Info, fetchERC721nfo } from './tokenHelpers'; import { CATEGORY, CriteriaStateType, @@ -121,17 +124,49 @@ const validateGUILDData = async ( return {}; }; -const validationCriteria = async ( - condition: Rule -): Promise => { - if (condition.type === TYPE.GUILD) { - return validateGUILDData(condition); +const validateTokenData = async (condition:Rule):Promise =>{ + const data:PushData = condition.data; + const _contract = data.contract || "" + const _eip155Format = _contract.split(":") + + if(_eip155Format.length !==3){ + return {tokenError:"Invalid contract address"} } - if (condition.category === CATEGORY.CustomEndpoint) - return validateCustomEndpointData(condition); - return {}; -}; + const [chainId, address] = [parseInt(_eip155Format[1]), _eip155Format[2]] + + if(!ethers.utils.isAddress(address)){ + return {tokenError:`Invalid contract address`} + } + + const [err] = condition.category === CATEGORY.ERC721 ? + await fetchERC721nfo(address, chainId) : await fetchERC20Info(address, chainId); + + if(err){ + return {tokenError:`Invalid ${condition.category} contract`} + } + if(!data.amount){ + return {tokenAmount:`Amount cannot be 0`} + } + return {} +} + +const validationCriteria = async (condition: Rule):Promise => { + if(condition.type === TYPE.GUILD) + { + return validateGUILDData(condition); + }else{ + if(condition.category === CATEGORY.INVITE){ + return {} + }else if (condition.category === CATEGORY.CustomEndpoint){ + return validateCustomEndpointData(condition); + }else{ + return validateTokenData(condition) + } + } + +} + export { handleDefineCondition, validationCriteria, diff --git a/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/abi.ts b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/abi.ts new file mode 100644 index 000000000..d941608ab --- /dev/null +++ b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/abi.ts @@ -0,0 +1,32 @@ +const NFTContractABI = [ + { + inputs: [], + name: 'name', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + stateMutability: 'view', + type: 'function', + }, +]; + +const TokenContractABI = [ + { + constant: true, + inputs: [], + name: 'symbol', + outputs: [{ internalType: 'string', name: '', type: 'string' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, + { + constant: true, + inputs: [], + name: 'decimals', + outputs: [{ internalType: 'uint8', name: '', type: 'uint8' }], + payable: false, + stateMutability: 'view', + type: 'function', + }, +]; + +export { NFTContractABI, TokenContractABI }; diff --git a/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/chain.ts b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/chain.ts new file mode 100644 index 000000000..d632a72ee --- /dev/null +++ b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/chain.ts @@ -0,0 +1,31 @@ +const getInfuraUrlFor = (network: string, key: string) => + `https://${network}.infura.io/v3/${key}`; + +const getRpcURL = (network: string, key: string) => { + return getInfuraUrlFor(network, key); +}; + +export const getChainRPC = (chainId: number): string => { + // TODO: use key as secrect + const key = "183499af0dd447c782ffe67f3ef7fa11"; + switch (chainId) { + case 1: + return getRpcURL("mainnet", key); + case 137: + return getRpcURL("polygon-mainnet", key); + case 10: + return getRpcURL("optimism-mainnet", key); + case 56: + return "https://bsc-dataseed.binance.org/"; + case 5: + return getRpcURL("goerli", key); + case 420: + return getRpcURL("optimism-goerli", key); + case 80001: + return getRpcURL("polygon-mumbai", key); + case 97: + return "https://data-seed-prebsc-1-s1.binance.org:8545"; + default: + return getRpcURL("mainnet", key); + } +}; diff --git a/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/fetch.ts b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/fetch.ts new file mode 100644 index 000000000..0031f06c0 --- /dev/null +++ b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/fetch.ts @@ -0,0 +1,114 @@ +import { ethers } from "ethers"; +import { getChainRPC } from "./chain"; +import { NFTContractABI, TokenContractABI } from "./abi"; +import { CATEGORY } from "../../types"; + +interface ERC20InfoType { + symbol: string; + decimals: number; +} + +export const tokenFetchHandler = async ( + contract: string, + type: string, + category: string, + chainInfo: string, + setUnit: (value: React.SetStateAction) => void, + setDecimals: (value: React.SetStateAction) => void +): Promise<[boolean, string]> => { + const isValid = ethers.utils.isAddress(contract); + + if (type === 'GUILD') { + return [false, '']; + } + + if (!isValid) { + if(category === CATEGORY.ERC20){ + setUnit('TOKEN'); + }else{ + setUnit('NFT'); + } + return [true, `${contract} is invalid invalid contract address`]; + } + + const _chainId = parseInt(chainInfo.split(':')[1]); + + if (category === CATEGORY.ERC20) { + // erc 20 logic + const [isErr, tokenInfo] = await fetchERC20Info(contract, _chainId); + if (isErr) { + // handle error + const errMessage = `${contract} is invalid ERC20 on chain ${_chainId}`; + setUnit('TOKEN'); + setDecimals(18); + + return [true, errMessage]; + } else { + // set the token info + setUnit(tokenInfo.symbol); + setDecimals(tokenInfo.decimals); + + return [false, '']; + } + } else { + // erc 721 logic + const [isErr, tokenInfo] = await fetchERC721nfo(contract, _chainId); + if (isErr) { + // handle error + const errMessage = `${contract} is invalid ERC721 on chain ${_chainId}`; + setUnit('NFT'); + setDecimals(18); + return [true, errMessage]; + } else { + // set the token info + setUnit(tokenInfo); + return [false, '']; + } + } +}; + + +export const fetchERC20Info = async ( + contractAddress: string, + chainId: number +): Promise<[boolean, ERC20InfoType]> => { + try { + const rpcURL = getChainRPC(chainId); + const provider = new ethers.providers.JsonRpcProvider(rpcURL); + const contract:any = new ethers.Contract( + contractAddress, + TokenContractABI, + provider + ); + + const [symbol, decimals] = await Promise.all([ + contract.symbol(), + contract.decimals(), + ]); + + return [false, { symbol: symbol, decimals: decimals }]; + } catch { + return [true, { symbol: "", decimals: 0 }]; + } +}; + +export const fetchERC721nfo = async ( + contractAddress: string, + chainId: number +): Promise<[boolean, string]> => { + try { + const rpcURL = getChainRPC(chainId); + const provider = new ethers.providers.JsonRpcProvider(rpcURL); + + const contract:any = new ethers.Contract( + contractAddress, + NFTContractABI, + provider + ); + + const name = await contract.name(); + return [false, name]; + } catch { + return [true, ""]; + } +}; diff --git a/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/index.ts b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/index.ts new file mode 100644 index 000000000..385aa3373 --- /dev/null +++ b/packages/uiweb/src/lib/components/chat/helpers/tokenHelpers/index.ts @@ -0,0 +1,3 @@ +export * from './abi'; +export * from './fetch'; +export * from './chain'; diff --git a/packages/uiweb/src/lib/components/chat/reusables/QuantityInput.tsx b/packages/uiweb/src/lib/components/chat/reusables/QuantityInput.tsx index 727a933fe..ac3e134cf 100644 --- a/packages/uiweb/src/lib/components/chat/reusables/QuantityInput.tsx +++ b/packages/uiweb/src/lib/components/chat/reusables/QuantityInput.tsx @@ -7,6 +7,7 @@ import { DropdownValueType } from './DropDown'; import { Div, Section, Span } from '../../reusables'; import { DropDownInput } from './DropDownInput'; import { device } from '../../../config'; +import { shortenText } from '../../../helpers'; export type InputType = { value: number; range: number }; export interface IQuantityInputProps { @@ -16,6 +17,7 @@ export interface IQuantityInputProps { unit: string; onInputChange: any; dropDownValues: DropdownValueType[]; + error?:boolean; } export const QuantityInput = (props: IQuantityInputProps) => { @@ -41,6 +43,7 @@ export const QuantityInput = (props: IQuantityInputProps) => {
{ background={theme.backgroundColor?.modalHoverBackground} width='fit-content' height='fit-content' + error={props.error ||false} > - {props.unit} + {shortenText(props.unit,15)}
@@ -81,13 +85,13 @@ const LabelContainer = styled.div` font-size: 16px; color: ${(props) => props.theme.textColor?.modalHeadingText ?? '#000'}; `; -const Input = styled.input` +const Input = styled.input` padding: 16px; margin-top: 8px; color: ${(props) => props.theme.textColor?.modalHeadingText ?? '#000'}; background: ${(props) => props.theme.backgroundColor.modalInputBackground}; - border: ${(props) => props.theme.border.modalInnerComponents}; + border: ${(props) => props.error?' 1px solid #ED5858':props.theme.border.modalInnerComponents}; border-width: 1px 0px 1px 1px; border-radius: 12px 0 0 12px; font-family: ${(props) => props.theme.fontFamily}; @@ -96,18 +100,19 @@ const Input = styled.input` font-weight: 500; `; -const Unit = styled(Section)` +const Unit = styled(Section)` span{ font-size:14px; font-weight:700; + text-wrap: nowrap; } border-radius:0 12px 12px 0; -padding:17.2px; -border: ${(props) => props.theme.border.modalInnerComponents}; +padding:18.2px 17.2px 17.2px 17.2px; +border: ${(props) => props.error?' 1px solid #ED5858':props.theme.border.modalInnerComponents}; @media ${device.mobileL} { - padding:19.5px 5px; + padding:21.5px 5px 18px 5px; span{ font-size: 10px; font-weight:400 diff --git a/packages/uiweb/src/lib/components/chat/theme/index.ts b/packages/uiweb/src/lib/components/chat/theme/index.ts index de2b3b886..e703dbc29 100644 --- a/packages/uiweb/src/lib/components/chat/theme/index.ts +++ b/packages/uiweb/src/lib/components/chat/theme/index.ts @@ -36,6 +36,7 @@ interface IBackgroundColor { toastSuccessBackground?: string; toastErrorBackground?: string; toastShadowBackground?: string; + criteriaLabelBackground?:string; } interface ITextColor { @@ -138,6 +139,7 @@ export const lightChatTheme: IChatTheme = { toastErrorBackground: 'linear-gradient(90.15deg, #FF2070 -125.65%, #FF2D79 -125.63%, #FFF9FB 42.81%)', toastShadowBackground: '#ccc', + criteriaLabelBackground: '#657795' }, fontSize: { @@ -226,6 +228,7 @@ export const darkChatTheme: IChatTheme = { encryptionMessageBackground: 'rgb(64, 70, 80);', buttonBackground: 'rgb(202, 89, 155)', modalBackground:'rgb(47, 49, 55)', + criteriaLabelBackground: 'rgb(47, 49, 55)', modalInputBackground:'transparent', modalHoverBackground:'rgb(64, 70, 80)', buttonDisableBackground:'#787E99', diff --git a/packages/uiweb/src/lib/components/chat/types/tokenGatedGroupCreationType.ts b/packages/uiweb/src/lib/components/chat/types/tokenGatedGroupCreationType.ts index c10b54b4f..2cf751f25 100644 --- a/packages/uiweb/src/lib/components/chat/types/tokenGatedGroupCreationType.ts +++ b/packages/uiweb/src/lib/components/chat/types/tokenGatedGroupCreationType.ts @@ -5,6 +5,7 @@ export interface PushData { inviterRoles?: string[]; comparison?:string; url?: string; + token?:string; } export interface GuildData { @@ -62,7 +63,7 @@ export interface CriteriaStateType { selectedRules: Rule[]; setSelectedRule: React.Dispatch>; addNewCondtion: () => void; - addNewRule: (newRule: Rule) => void; + addNewRule: (newRule: Rule) => boolean; deleteRule: (idx: number) => void; deleteEntryOptionsDataArray: (idx: number) => void; selectEntryOptionsDataArrayForUpdate: (idx: number) => void; @@ -76,13 +77,16 @@ export interface CriteriaStateType { export type CriteriaValidationErrorType = { - + //guild error guildId?:string, guildComparison?:string; guildRole?:string; groupName?:string; groupDescription?:string; + //token error + tokenError?:string + tokenAmount?:string; //custom endpoint errors url?:string; } \ No newline at end of file diff --git a/packages/uiweb/src/lib/hooks/chat/useCriteriaState.ts b/packages/uiweb/src/lib/hooks/chat/useCriteriaState.ts index e677f4251..1a31b8e82 100644 --- a/packages/uiweb/src/lib/hooks/chat/useCriteriaState.ts +++ b/packages/uiweb/src/lib/hooks/chat/useCriteriaState.ts @@ -30,7 +30,40 @@ export const useCriteriaState = ( useState(-1); const [updateCriteriaIdx, setUpdateCriteriaIdx] = useState(-1); + + const isDuplicateRule = (rule:Rule)=>{ + const newRule = JSON.stringify(rule) + + // check on current conditions + for(let i=0; i { + if(isDuplicateRule(newRule)){ + return false + } + if (selectedCriteria === -1) { setSelectedCriteria(entryOptionTypeArray.length); } @@ -44,6 +77,8 @@ export const useCriteriaState = ( // add new setSelectedRule((prev) => [...prev, newRule]); } + + return true }; const deleteRule = (idx: number) => {