diff --git a/client/config.ts b/client/config.ts index 21950138..47430fd0 100644 --- a/client/config.ts +++ b/client/config.ts @@ -13,6 +13,10 @@ const config = { sentryDSN: process.env.SENTRY_DSN || '', backendHost: process.env.BACKEND_HOST || 'https://mdo-dcobackend-01.t.hmny.io', + freeRentBackendHost: + process.env.FREE_RENT_BACKEND_HOST || 'https://1-country-api.fly.dev', + freeRentKey: + process.env.FREE_RENT_KEY || 'ZXRsLXNlcnZeeY2USS6QXNCkZmdoYjc5MA', registrar: process.env.REGISTRAR_RELAYER || 'https://1ns-registrar-relayer.hiddenstate.xyz', diff --git a/client/src/Routes.tsx b/client/src/Routes.tsx index a3df1016..baafd64f 100644 --- a/client/src/Routes.tsx +++ b/client/src/Routes.tsx @@ -10,7 +10,10 @@ const StatusPage = lazy( () => import(/* webpackChunkName: "Others" */ './routes/status/Status') ) const AffiliateSalesPage = lazy( - () => import(/* webpackChunkName: "Others" */ './routes/affiliate/AffiliateStatus') + () => + import( + /* webpackChunkName: "Others" */ './routes/affiliate/AffiliateStatus' + ) ) const WaitingRoom = lazy( () => @@ -30,6 +33,12 @@ const OpenWidgetsPage = lazy( /* webpackChunkName: "Others" */ './routes/openWidgets/OpenWidgetsPage' ) ) + +const ClaimFreePage = lazy( + () => + import(/* webpackChunkName: "Others" */ './routes/claimFree/ClaimFreePage') +) + console.log('### WaitingRoom', WaitingRoom) const AppRoutes = () => { @@ -44,6 +53,7 @@ const AppRoutes = () => { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/api/mainApi.ts b/client/src/api/mainApi.ts index f723e990..c965f39e 100644 --- a/client/src/api/mainApi.ts +++ b/client/src/api/mainApi.ts @@ -27,10 +27,10 @@ export interface Link { export interface HtmlWidget { id: string attributes: { - any: string; - }; - title: string; - owner: string; + any: string + } + title: string + owner: string } export const mainApi = { @@ -50,6 +50,33 @@ export const mainApi = { }) }, + rentDomainForFree: ({ + name, + owner, + freeRentKey, + }: { + name: string + owner: string + freeRentKey: string + }) => { + return axios.post<{ transactionHash: string }>( + `${config.freeRentBackendHost}/rent`, + { + domainName: name, + ownerAddress: owner, + freeRentKey, + } + ) + }, + + isHasClaim: async ({ address }: { address: string }) => { + const response = await axios.get<{ hasClaim: boolean }>( + `${config.freeRentBackendHost}/hasClaim/${address}` + ) + + return response.data.hasClaim + }, + loadDomain: async ({ domain }: { domain: string }) => { const response = await base.get<{ data: Domain }>(`/domains/${domain}`) return response.data.data @@ -96,16 +123,18 @@ export const mainApi = { return base.post<{ data: Link }>(`/links/`, { domainName, linkId, - url + url, }) }, - pinLink: (id: string, isPinned: boolean) => base.post<{ data: Link }>(`/links/pin`, { - id, - isPinned - }), + pinLink: (id: string, isPinned: boolean) => + base.post<{ data: Link }>(`/links/pin`, { + id, + isPinned, + }), - getLinks: (domainName: string) => base.get<{ data: Link[] }>(`/links?domain=${domainName}`), + getLinks: (domainName: string) => + base.get<{ data: Link[] }>(`/links?domain=${domainName}`), deleteLink: (id: string) => base.delete<{ data: string }>(`/links/${id}`), @@ -113,10 +142,10 @@ export const mainApi = { return base.post(`/widgets/`, { attributes, owner, - title + title, }) }, - + getHtmlWidget: (id: string) => base.get(`/widgets/${id}`), auth: async ({ @@ -144,4 +173,4 @@ export const mainApi = { .then((result) => result.status === 200) .catch(() => false) }, -} \ No newline at end of file +} diff --git a/client/src/components/TextInput.tsx b/client/src/components/TextInput.tsx new file mode 100644 index 00000000..bcd299a1 --- /dev/null +++ b/client/src/components/TextInput.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { TextInput as GrommetTextInput } from 'grommet/components/TextInput' + +import { palette } from '../constants' +import styled, { css } from 'styled-components' + +export const TextInput = styled(GrommetTextInput)<{ isValid?: boolean }>` + border-radius: 20px; + box-shadow: none; + font-weight: 400; + border: 1px solid #dfe1e5; + color: #333437; + transition: border-color 250ms, box-shadow 250ms; + + &:focus, + &:hover { + background-color: #fff; + box-shadow: 0 1px 5px rgb(32 33 36 / 26%); + border-color: rgba(223, 225, 229, 0); + } + + ${(props) => + !props.isValid && + css` + border-color: ${palette.PinkRed}; + + &:hover, + &:focus { + border-color: ${palette.LightRed}; + box-shadow: 0 1px 6px rgb(255 77 79 / 26%); + } + `} +` diff --git a/client/src/components/search-input/SearchInput.tsx b/client/src/components/search-input/SearchInput.tsx index 4888211e..df2beb92 100644 --- a/client/src/components/search-input/SearchInput.tsx +++ b/client/src/components/search-input/SearchInput.tsx @@ -1,38 +1,10 @@ import React, { useRef } from 'react' import { Box } from 'grommet/components/Box' -import { TextInput, TextInputProps } from 'grommet/components/TextInput' +import { TextInputProps } from 'grommet/components/TextInput' import { FormClose } from 'grommet-icons/icons/FormClose' import styled, { css } from 'styled-components' -import { palette } from '../../constants' - -const TextInputWrapper = styled(TextInput)<{ isValid?: boolean }>` - border-radius: 20px; - box-shadow: none; - font-weight: 400; - border: 1px solid #dfe1e5; - color: #333437; - transition: border-color 250ms, box-shadow 250ms; - - &:focus, - &:hover { - background-color: #fff; - box-shadow: 0 1px 5px rgb(32 33 36 / 26%); - border-color: rgba(223, 225, 229, 0); - } - - ${(props) => - !props.isValid && - css` - border-color: ${palette.PinkRed}; - - &:hover, - &:focus { - border-color: ${palette.LightRed}; - box-shadow: 0 1px 6px rgb(255 77 79 / 26%); - } - `} -` +import { TextInput } from '../TextInput' const InputSuffix = styled(Box)` position: absolute; @@ -105,7 +77,7 @@ export const SearchInput = (props: SearchInputProps) => { justify={'center'} paddingLeft={props.icon ? null : '24px'} > - + {allowClear && !props.disabled && props.value && ( diff --git a/client/src/custom.d.ts b/client/src/custom.d.ts index bf471bdd..0e78a1ee 100644 --- a/client/src/custom.d.ts +++ b/client/src/custom.d.ts @@ -14,4 +14,5 @@ declare module 'react-video-thumbnail-image' declare module 'react-fb-image-video-grid' declare module 'grommet-icons/icons/FormClose' declare module 'grommet-icons/icons/FormSearch' +declare module 'grommet-icons/icons/User' declare module 'use-lodash-debounce' diff --git a/client/src/routes/claimFree/ClaimFreePage.tsx b/client/src/routes/claimFree/ClaimFreePage.tsx new file mode 100644 index 00000000..0bfe6577 --- /dev/null +++ b/client/src/routes/claimFree/ClaimFreePage.tsx @@ -0,0 +1,610 @@ +import React, { useEffect, useMemo, useState } from 'react' +import styled from 'styled-components' +import debounce from 'lodash.debounce' +import { observer } from 'mobx-react-lite' +import { useSearchParams } from 'react-router-dom' + +import { useAccount, useDisconnect } from 'wagmi' +import { useWeb3Modal, Web3Button } from '@web3modal/react' +import qs from 'qs' + +import { sleep } from '../../utils/sleep' +import { + ProcessStatus, + ProcessStatusItem, + ProcessStatusTypes, +} from '../../components/process-status/ProcessStatus' +import { relayApi, RelayError } from '../../api/relayApi' +import { HomeSearchResultItem } from '../home/components/HomeSearchResultItem' +import { useStores } from '../../stores' +import config from '../../../config' +import { mainApi } from '../../api/mainApi' +import { RESERVED_DOMAINS } from '../../utils/reservedDomains' +import logger from '../../modules/logger' +const log = logger.module('ClaimFreePage') + +import { Button } from '../../components/Controls' +import { BaseText } from '../../components/Text' +import { FlexRow } from '../../components/Layout' +import { DomainPrice, DomainRecord } from '../../api' +import { nameUtils, validateDomainName } from '../../api/utils' +import { SearchInput } from '../../components/search-input/SearchInput' +import { FormSearch } from 'grommet-icons/icons/FormSearch' +import { User } from 'grommet-icons/icons/User' + +import { Box } from 'grommet/components/Box' +import { Text } from 'grommet/components/Text' +import { Container } from '../home/Home.styles' +import { TextInput } from '../../components/TextInput' +import { ethers } from 'ethers' + +const SearchBoxContainer = styled(Box)` + width: 100%; + max-width: 800px; + margin: 0 auto; +` + +interface SearchResult { + domainName: string + domainRecord: DomainRecord + price: DomainPrice + isAvailable: boolean + error: string +} + +const ClaimFreePage: React.FC = observer(() => { + const { isConnected, address, connector, status } = useAccount() + const { disconnect } = useDisconnect() + const { open, close, isOpen } = useWeb3Modal() + const [searchParams] = useSearchParams() + const [inputValue, setInputValue] = useState(searchParams.get('domain') || '') + const [freeRentKey, setFreeRentKey] = useState( + searchParams.get('freeRentKey') || config.freeRentKey + ) + const [claimAddress, setClaimAddress] = useState(address || '') + const [isLoading, setLoading] = useState(false) + const [processStatus, setProcessStatus] = useState({ + type: ProcessStatusTypes.IDLE, + render: '', + }) + + useEffect(() => { + setClaimAddress(address) + }, [address]) + + const [validation, setValidation] = useState({ valid: true, error: '' }) + + const [web2Error, setWeb2Error] = useState(false) + const [secret] = useState(Math.random().toString(26).slice(2)) + const [regTxHash, setRegTxHash] = useState('') + const [web2Acquired, setWeb2Acquired] = useState(false) + const [searchResult, setSearchResult] = useState(null) + const { rootStore, ratesStore, walletStore, utilsStore } = useStores() + + useEffect(() => { + if (status === 'connecting') { + if (!isOpen && !walletStore.isMetamaskAvailable) { + // User declined connect with Wallet Connect + disconnect() + setProcessStatus({ + type: ProcessStatusTypes.IDLE, + render: '', + }) + terminateProcess(0) + } + } + }, [status, isOpen]) + + const updateSearch = useMemo(() => { + return debounce(async (domainName: string) => { + setSearchResult(null) + if (domainName) { + const result = validateDomainName(domainName) + setValidation(result) + + if (domainName.length <= 2) { + setValidation({ + valid: false, + error: 'The length of the domains must exceed 2 characters', + }) + return + } + + if (result.valid) { + try { + setProcessStatus({ + type: ProcessStatusTypes.PROGRESS, + render: '', + }) + setLoading(true) + + const result = await loadDomainRecord(domainName) + setSearchResult(result) + + setProcessStatus({ + type: ProcessStatusTypes.IDLE, + render: '', + }) + } catch (e) { + console.log('### update search errors', e) + setProcessStatus({ + type: ProcessStatusTypes.IDLE, + render: {e.message}, + }) + } finally { + setLoading(false) + } + } + } else { + setValidation({ valid: true, error: '' }) + } + }, 350) + }, [rootStore.d1dcClient]) + + // setup form from query string + useEffect(() => { + if (inputValue) { + updateSearch(inputValue) + } + }, []) + + useEffect(() => { + if (web2Acquired) { + const queryString = qs.stringify({ + domain: searchResult.domainName, + }) + + window.location.href = `${config.hostname}/new?${queryString}` + } + }, [web2Acquired]) + + useEffect(() => { + const connectWallet = async () => { + const provider = await connector!.getProvider() + walletStore.setProvider(provider, claimAddress) + handleRentDomain() + } + + if (!walletStore.isMetamaskAvailable) { + if (isConnected) { + connectWallet() + } else { + // Wallet Connect disconnected, drop to initial state + if (processStatus.type === ProcessStatusTypes.PROGRESS) { + terminateProcess(1) + } + } + } + }, [isConnected]) + + const handleSearchChange = (value: string) => { + setInputValue(value) + updateSearch(value) + setWeb2Error(false) + + if (!value && processStatus.type === ProcessStatusTypes.ERROR) { + setProcessStatus({ type: ProcessStatusTypes.IDLE, render: '' }) + } + } + + const terminateProcess = async (timer: number = 5000) => { + await sleep(timer) + setLoading(false) + } + + const relayCheck = (_domainName: string) => { + if (_domainName.length <= 2) { + return { + isAvailable: true, + error: '', + } + } + if ( + _domainName.length === 3 && + RESERVED_DOMAINS.find( + (value) => value.toLowerCase() === _domainName.toLowerCase() + ) + ) { + return { + isAvailable: true, + error: '', + } + } + return relayApi().checkDomain({ + sld: _domainName, + }) + } + + const loadDomainRecord = async (_domainName: string) => { + const [record, price, relayCheckDomain, isAvailable2] = await Promise.all([ + rootStore.d1dcClient.getRecord({ name: _domainName }), + rootStore.d1dcClient.getPrice({ name: _domainName }), + relayCheck(_domainName), + rootStore.d1dcClient.checkAvailable({ + name: _domainName, + }), + ]) + console.log('WEB3', _domainName, isAvailable2) + console.log('WEB2', _domainName, relayCheckDomain.isAvailable) + + return { + domainName: _domainName, + domainRecord: record, + price: price, + error: relayCheckDomain.error, + isAvailable: relayCheckDomain.isAvailable && isAvailable2, + } + } + + const claimWeb2DomainWrapper = async () => { + setLoading(true) + try { + if ( + searchResult.domainName.length !== 3 || + !RESERVED_DOMAINS.find( + (value) => + value.toLowerCase() === searchResult.domainName.toLowerCase() + ) + ) { + await claimWeb2Domain(regTxHash) + } + await sleep(2000) + await generateNFT() + setProcessStatus({ + render: NFT generated., + }) + await sleep(2000) + setProcessStatus({ + render: Web2 domain acquired, + }) + terminateProcess() + setWeb2Acquired(true) + } catch (ex) { + setWeb2Error(true) + setProcessStatus({ + type: ProcessStatusTypes.ERROR, + render: ( + {`${ + ex instanceof RelayError + ? ex.message + : 'Unable to acquire domain. Try Again.' + }`} + ), + }) + + log.error('claimWeb2DomainWrapper', { + error: ex instanceof RelayError ? ex.message : ex, + domain: `${searchResult?.domainName?.toLowerCase()}${config.tld}`, + txHash: regTxHash, + address: walletStore.walletAddress, + }) + + terminateProcess() + } + } + + const claimWeb2Domain = async (txHash: string) => { + const domain = searchResult.domainName + config.tld + const messages = [ + `contacting dns server`, + `setting dns record for ${domain}`, + `verifying dns record for ${domain}`, + `setting up ${domain}`, + `creating ${domain} landing page`, + `adding transaction details of ${domain}`, + `creating SSL certificate for ${domain}`, + `verifying SSL certificate for ${domain}`, + `adding SSL certificate to ${domain}`, + ] + + let messageIndex = 0 + + const createTick = () => { + return setTimeout(() => { + messageIndex++ + if (messageIndex > messages.length - 1) { + return + } + + const message = messages[messageIndex] + setProcessStatus({ + type: ProcessStatusTypes.PROGRESS, + render: {message}, + }) + + createTick() + }, 10000) + } + + const timerId = createTick() + + try { + // @ts-ignore + const { success, responseText, isRegistered } = + await relayApi().purchaseDomain({ + domain: `${searchResult.domainName.toLowerCase()}${config.tld}`, + txHash, + address: walletStore.walletAddress, + }) + clearTimeout(timerId) + if (!success && !isRegistered) { + console.log(`failure reason: ${responseText}`) + throw new RelayError( + `Unable to acquire web2 domain. Reason: ${responseText}` + ) + } + } catch (error) { + clearTimeout(timerId) + console.log('### ex', error?.response?.data) + throw new RelayError( + error?.response?.data?.responseText || `Unable to acquire web2 domain` + ) + } + } + + const generateNFT = async () => { + const domain = searchResult.domainName + config.tld + try { + await relayApi().genNFT({ + domain, + }) + } catch (error) { + console.log(error) + throw new RelayError( + error?.response?.data?.responseText || `Unable to genereate the NFT` + ) + } + } + + const handleGoToDomain = (searchResult: SearchResult) => { + window.location.href = `https://${searchResult.domainName.toLowerCase()}${ + config.tld + }` + } + + const handleRentDomain = async () => { + if (!searchResult || !searchResult.domainRecord || !validation.valid) { + return false + } + + setLoading(true) + + setProcessStatus({ + type: ProcessStatusTypes.PROGRESS, + render: Checking domain, + }) + + console.log('### searchResult', searchResult) + + const hasClaim = await mainApi.isHasClaim({ address }) + + if (hasClaim) { + setValidation({ + valid: false, + error: 'You can claim only one domain', + }) + setLoading(false) + + setProcessStatus({ + type: ProcessStatusTypes.IDLE, + render: '', + }) + + return + } + + const _available = await rootStore.d1dcClient.checkAvailable({ + name: searchResult.domainName, + }) + if (!_available) { + setValidation({ + valid: false, + error: 'This domain name is already registered', + }) + setLoading(false) + + setProcessStatus({ + type: ProcessStatusTypes.IDLE, + render: '', + }) + return + } + + if (!searchResult.domainName) { + setValidation({ + valid: false, + error: 'Invalid domain', + }) + return + } + if (!nameUtils.isValidName(searchResult.domainName)) { + setValidation({ + valid: false, + error: 'Domain must be alphanumerical characters', + }) + setLoading(false) + return + } + + setProcessStatus({ + type: ProcessStatusTypes.PROGRESS, + render: Processing transaction, + }) + + try { + if (walletStore.isMetamaskAvailable && !walletStore.isConnected) { + setProcessStatus({ + type: ProcessStatusTypes.PROGRESS, + render: Connect Metamask, + }) + await walletStore.connect() + } else if (!isConnected) { + open() + return + } + } catch (e) { + setProcessStatus({ + type: ProcessStatusTypes.ERROR, + render: {e.message}, + }) + terminateProcess(1500) + if (e.name === 'UserRejectedRequestError') { + open() + } + console.log('Connect error:', { e }) + return + } + + try { + let txHash + + const rentResult = await mainApi.rentDomainForFree({ + name: searchResult.domainName.toLowerCase(), + owner: walletStore.walletAddress, + freeRentKey, + }) + + txHash = rentResult.data.transactionHash + + setRegTxHash(txHash) + + const referral = utilsStore.getReferral() + + mainApi.createDomain({ + domain: searchResult.domainName, + txHash, + referral, + }) + if ( + searchResult.domainName.length !== 3 || + !RESERVED_DOMAINS.find( + (value) => + value.toLowerCase() === searchResult.domainName.toLowerCase() + ) + ) { + await claimWeb2Domain(txHash) + } + setProcessStatus({ + render: Web2 domain acquired., + }) + await sleep(2000) + await generateNFT() + setProcessStatus({ + render: NFT generated., + }) + await sleep(2000) + terminateProcess() + setWeb2Acquired(true) + } catch (ex) { + setWeb2Error(true) + setProcessStatus({ + render: ( + {`${ + ex instanceof RelayError + ? ex.message + : 'Unable to acquire domain. Try Again.' + }`} + ), + }) + + log.error('claimWeb2Domain', { + error: ex instanceof RelayError ? ex.message : ex, + domain: `${searchResult?.domainName?.toLowerCase()}${config.tld}`, + txHash: regTxHash, + address: walletStore.walletAddress, + }) + + terminateProcess() + } + } + + return ( + + + + {isConnected && !walletStore.isMetamaskAvailable && ( + + + + )} + + {/**/} + {/* {*/} + {/* setClaimAddress(event.target.value)*/} + {/* }}*/} + {/* icon={}*/} + {/* value={claimAddress}*/} + {/* isValid={ethers.utils.isAddress(claimAddress)}*/} + {/* />*/} + {/**/} + + } + onSearch={handleSearchChange} + /> + + + {validation.valid && + !isLoading && + searchResult && + !web2Acquired && + !web2Error ? ( + + + {searchResult.isAvailable && ( + + )} + {!searchResult.isAvailable && validation.valid && ( + + )} + + ) : ( + + {!validation.valid && ( + + {validation.error} + + )} + {processStatus.type !== ProcessStatusTypes.IDLE && ( + + )} + + )} + {web2Error && ( + + + + )} + + + + ) +}) + +export default ClaimFreePage