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