feat(Frontend): Transfers FE improvements (#187)
This commit is contained in:
parent
ed879d4d91
commit
e7cd6b1151
55 changed files with 7767 additions and 3440 deletions
20
.github/workflows/frontend.yml
vendored
Normal file
20
.github/workflows/frontend.yml
vendored
Normal file
|
@ -0,0 +1,20 @@
|
|||
name: Frontend
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/frontend.yml
|
||||
- apps/transfers/frontend/**
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/transfers/frontend
|
||||
jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
check-latest: true
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
|
@ -1,7 +1,5 @@
|
|||
NEXT_PUBLIC_CHAIN_ID=testing
|
||||
NEXT_PUBLIC_CHAIN_RPC_URL=http://0.0.0.0:26657
|
||||
NEXT_PUBLIC_CHAIN_REST_URL=http://0.0.0.0:1317
|
||||
NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY=02ef4f843722d9badf8f5571d8f20cd1a21022fe52b9257d3a235c85dfc0ce11c0
|
||||
NEXT_PUBLIC_TARGET_CHAIN=localWasm
|
||||
NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY=02360955ff74750f6ea0b539f41cce89451f591e4c835d0a5406e6effa96dd169d
|
||||
NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS=wasm1jfgr0vgunezkhfmdy7krrupu6yjhx224nxtjptll2ylkkqhyzeshrspu9
|
||||
|
||||
# E2E Testing
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"extends": "next/core-web-vitals",
|
||||
"plugins": ["react-compiler"],
|
||||
"rules": {
|
||||
"react-compiler/react-compiler": "error"
|
||||
}
|
||||
"extends": [
|
||||
"next",
|
||||
"plugin:tailwindcss/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
{
|
||||
"plugins": ["prettier-plugin-tailwindcss"],
|
||||
"quoteProps": "consistent",
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"singleAttributePerLine": true,
|
||||
"tailwindFunctions": ["tw", "twJoin", "twMerge"],
|
||||
"tailwindPreserveWhitespace": true,
|
||||
"tailwindFunctions": ["twJoin", "twMerge"],
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
7
apps/transfers/frontend/env.d.ts
vendored
Normal file
7
apps/transfers/frontend/env.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY: string
|
||||
NEXT_PUBLIC_TARGET_CHAIN: string
|
||||
NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS: string
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
reactCompiler: true,
|
||||
instrumentationHook: true
|
||||
},
|
||||
}
|
||||
|
||||
|
|
8989
apps/transfers/frontend/package-lock.json
generated
8989
apps/transfers/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -1,53 +1,49 @@
|
|||
{
|
||||
"name": "transfer-fe",
|
||||
"name": "transfers-fe",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"generate": "node ./scripts/generate.js && node ./scripts/rebuild-component-index.js",
|
||||
"rebuild-component-index": "node ./scripts/rebuild-component-index.js"
|
||||
"test:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cosmjs/cosmwasm-stargate": "^0.32.4",
|
||||
"@cosmwasm/ts-codegen": "^1.11.1",
|
||||
"eciesjs": "^0.4.7",
|
||||
"graz": "^0.1.19",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^15.0.0-rc.0",
|
||||
"react": "^19.0.0-rc-f994737d14-20240522",
|
||||
"react-dom": "^19.0.0-rc-f994737d14-20240522",
|
||||
"next": "14.2.7",
|
||||
"notistack": "3.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"zod": "^3.23.8"
|
||||
"zustand": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@keplr-wallet/types": "0.12.103",
|
||||
"@netlify/plugin-nextjs": "^5.3.3",
|
||||
"@playwright/test": "^1.46.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/lodash": "^4.17.5",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "npm:types-react@rc",
|
||||
"@types/react-dom": "npm:types-react-dom@rc",
|
||||
"@types/react": "18.3.4",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/unzipper": "^0.10.10",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
|
||||
"dotenv": "16.4.5",
|
||||
"eslint": "^8",
|
||||
"eslint-plugin-react-compiler": "^0.0.0",
|
||||
"eslint-config-next": "^14.2.7",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-tailwindcss": "^3.17.4",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"postcss": "^8",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"typescript": "^5",
|
||||
"unzipper": "^0.12.3"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@rc",
|
||||
"@types/react-dom": "npm:types-react-dom@rc"
|
||||
}
|
||||
}
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 28 KiB |
|
@ -1,105 +0,0 @@
|
|||
const fs = require('fs')
|
||||
const readline = require('node:readline')
|
||||
|
||||
const componentsDirectory = `${__dirname}/../src/components`
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
})
|
||||
|
||||
rl.question(
|
||||
`What do you want to name your new component? `,
|
||||
(newComponentName) => {
|
||||
newComponentName = newComponentName || 'NewComponent'
|
||||
|
||||
rl.question(
|
||||
`What tag name do you want to use for your new component? (default: div) `,
|
||||
(tagName) => {
|
||||
tagName = tagName || 'div'
|
||||
|
||||
rl.question(
|
||||
`Is this a complex component? (Y/N, default: N)`,
|
||||
(isComplex) => {
|
||||
isComplex = isComplex === 'Y' ? true : false
|
||||
|
||||
const replacementsMap = {
|
||||
'NewComponent': newComponentName,
|
||||
'div': tagName,
|
||||
'//[^\n]+': '',
|
||||
}
|
||||
|
||||
if (isComplex) {
|
||||
const componentDirectory = `${componentsDirectory}/${newComponentName}`
|
||||
|
||||
// Make sure components directory exists
|
||||
fs.mkdirSync(componentsDirectory, { recursive: true })
|
||||
|
||||
// Create the new component directory and copy the template files
|
||||
fs.cpSync(
|
||||
`${__dirname}/templates/NewComponent`,
|
||||
componentDirectory,
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
)
|
||||
|
||||
const template = fs.readFileSync(
|
||||
`${__dirname}/templates/NewComponent/NewComponent.tsx`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const mergedTemplate = Object.entries(replacementsMap).reduce(
|
||||
(acc, [key, value]) => {
|
||||
return acc.replace(new RegExp(`${key}`, 'isg'), value)
|
||||
},
|
||||
template,
|
||||
)
|
||||
|
||||
const newComponentFile = `${componentDirectory}/NewComponent.tsx`
|
||||
|
||||
fs.writeFileSync(newComponentFile, mergedTemplate)
|
||||
|
||||
fs.writeFileSync(
|
||||
`${componentDirectory}/index.ts`,
|
||||
`export { ${newComponentName} } from './${newComponentName}'\n`,
|
||||
)
|
||||
|
||||
fs.renameSync(
|
||||
newComponentFile,
|
||||
`${componentDirectory}/${newComponentName}.tsx`,
|
||||
)
|
||||
} else {
|
||||
const newComponentFile = `${componentsDirectory}/${newComponentName}.tsx`
|
||||
|
||||
fs.cpSync(
|
||||
`${__dirname}/templates/NewComponent.tsx`,
|
||||
newComponentFile,
|
||||
)
|
||||
|
||||
const template = fs.readFileSync(
|
||||
`${__dirname}/templates/NewComponent.tsx`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const mergedTemplate = Object.entries(replacementsMap).reduce(
|
||||
(acc, [key, value]) => {
|
||||
return acc.replace(new RegExp(`${key}`, 'isg'), value)
|
||||
},
|
||||
template,
|
||||
)
|
||||
|
||||
fs.writeFileSync(newComponentFile, mergedTemplate)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Component ${newComponentName} created at ${componentsDirectory}`,
|
||||
)
|
||||
|
||||
rl.close()
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
|
@ -1,43 +0,0 @@
|
|||
const fs = require('fs')
|
||||
|
||||
const componentsDirectory = `${__dirname}/../src/components`
|
||||
const indexFile = `${componentsDirectory}/index.ts`
|
||||
|
||||
// Get directories and files in the components directory
|
||||
const filesAndDirectories = fs.readdirSync(componentsDirectory)
|
||||
|
||||
const [files, directories] = filesAndDirectories.reduce(
|
||||
(acc, fileOrDirectory) => {
|
||||
if (
|
||||
fs.statSync(`${componentsDirectory}/${fileOrDirectory}`).isDirectory()
|
||||
) {
|
||||
acc[1].push(fileOrDirectory)
|
||||
} else {
|
||||
acc[0].push(fileOrDirectory)
|
||||
}
|
||||
|
||||
return acc
|
||||
},
|
||||
[[], []],
|
||||
)
|
||||
|
||||
// Write them
|
||||
fs.writeFileSync(
|
||||
indexFile,
|
||||
filesAndDirectories
|
||||
.reduce((acc, fileOrDirectory) => {
|
||||
if (['.DS_Store', 'index.ts'].includes(fileOrDirectory)) {
|
||||
return [...acc]
|
||||
}
|
||||
|
||||
const componentName = fileOrDirectory.replace('.tsx', '')
|
||||
|
||||
return [...acc, `export { ${componentName} } from './${componentName}'`]
|
||||
}, [])
|
||||
.join('\n'),
|
||||
{
|
||||
flag: 'w',
|
||||
},
|
||||
)
|
||||
|
||||
console.log(`Rebuilt component index file at ${indexFile}`)
|
|
@ -1,24 +0,0 @@
|
|||
import { ComponentProps } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface NewComponentProps extends ComponentProps<'div'> {}
|
||||
|
||||
export function NewComponent({
|
||||
children,
|
||||
className,
|
||||
...otherProps
|
||||
}: NewComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
`
|
||||
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import { ComponentProps } from 'react'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { classNames } from './classNames'
|
||||
|
||||
interface NewComponentProps extends ComponentProps<'div'> {}
|
||||
|
||||
export function NewComponent({
|
||||
children,
|
||||
className,
|
||||
...otherProps
|
||||
}: NewComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(classNames.container, className)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
import { tw } from '@/lib/tw'
|
||||
|
||||
export const classNames = {
|
||||
container: tw`
|
||||
// styles here
|
||||
`,
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { NewComponent } from './NewComponent'
|
32
apps/transfers/frontend/src/app/(anon)/page.tsx
Normal file
32
apps/transfers/frontend/src/app/(anon)/page.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSuggestChainAndConnect } from 'graz'
|
||||
|
||||
import chain from '@/config/chain'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
import { useGlobalState } from '@/state/useGlobalState'
|
||||
|
||||
export default function Landing() {
|
||||
const router = useRouter()
|
||||
const { suggestAndConnect } = useSuggestChainAndConnect({
|
||||
onSuccess: () => router.replace('/set-seed'),
|
||||
})
|
||||
|
||||
const connectWallet = () => {
|
||||
useGlobalState.getState().setLoading(true)
|
||||
suggestAndConnect({ chainInfo: chain })
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center gap-4 p-24">
|
||||
<p>Connect your Keplr wallet to log in</p>
|
||||
<StyledText
|
||||
variant="button.primary"
|
||||
onClick={connectWallet}
|
||||
>
|
||||
Connect Keplr
|
||||
</StyledText>
|
||||
</main>
|
||||
)
|
||||
}
|
51
apps/transfers/frontend/src/app/(anon)/set-seed/page.tsx
Normal file
51
apps/transfers/frontend/src/app/(anon)/set-seed/page.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { EnterSeedModal } from '@/components/EnterSeedModal'
|
||||
import { generateMnemonic, saveMnemonic } from '@/lib/ephemeralKeypair'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
import { useGlobalState } from '@/state/useGlobalState'
|
||||
|
||||
const mnemonic = generateMnemonic()
|
||||
|
||||
export default function SetSeed() {
|
||||
const router = useRouter()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const acceptPhrase = () => {
|
||||
useGlobalState.getState().setLoading(true)
|
||||
saveMnemonic(mnemonic)
|
||||
router.replace('/dashboard')
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center gap-8 p-24">
|
||||
<h1>
|
||||
This will be your recovery seed phrase for your public/private keys:
|
||||
</h1>
|
||||
<code className="rounded bg-slate-500 p-2 font-bold text-white">
|
||||
{mnemonic}
|
||||
</code>
|
||||
<div className="flex flex-col gap-4">
|
||||
<StyledText
|
||||
variant="button.primary"
|
||||
onClick={acceptPhrase}
|
||||
>
|
||||
Continue with the autogenerated seed phrase
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
I want to enter my own recovery phrase instead
|
||||
</StyledText>
|
||||
</div>
|
||||
<EnterSeedModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
|
@ -2,22 +2,30 @@
|
|||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import {
|
||||
DepositModalWindow,
|
||||
Icon,
|
||||
Notifications,
|
||||
StyledText,
|
||||
TransferModalWindow,
|
||||
WithdrawModalWindow,
|
||||
} from '@/components'
|
||||
useAccount,
|
||||
useCosmWasmSigningClient,
|
||||
useDisconnect,
|
||||
useExecuteContract,
|
||||
useQuerySmart,
|
||||
} from 'graz'
|
||||
|
||||
import { tw } from '@/lib/tw'
|
||||
import { wasmEventHandler } from '@/lib/wasmEventHandler'
|
||||
import { FormActionResponse } from '@/lib/types'
|
||||
import { chain } from '@/lib/chainConfig'
|
||||
import { cosm } from '@/lib/cosm'
|
||||
import { wallet } from '@/lib/wallet'
|
||||
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { DepositModalWindow } from '@/components/DepositModalWindow'
|
||||
import { TransferModalWindow } from '@/components/TransferModalWindow'
|
||||
import { WithdrawModalWindow } from '@/components/WithdrawModalWindow'
|
||||
import {
|
||||
clearMnemonic,
|
||||
decrypt,
|
||||
getEphemeralKeypair,
|
||||
} from '@/lib/ephemeralKeypair'
|
||||
import chain from '@/config/chain'
|
||||
import { useGlobalState } from '@/state/useGlobalState'
|
||||
import { showError, showSuccess } from '@/lib/notifications'
|
||||
|
||||
function formatAmount(value: number) {
|
||||
return value.toLocaleString('en-US', {
|
||||
|
@ -41,97 +49,69 @@ const retrieveBalance = (data: string) => {
|
|||
return balance
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [requestBalanceResult, setRequestBalanceResult] =
|
||||
useState<FormActionResponse>()
|
||||
export default function Dashboard() {
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [walletAddress, setWalletAddress] = useState(
|
||||
wallet.getAccount().address,
|
||||
)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { data } = useAccount()
|
||||
const [isDepositModalOpen, setIsDepositModalOpen] = useState(false)
|
||||
const [isTransferModalOpen, setIsTransferModalOpen] = useState(false)
|
||||
const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false)
|
||||
|
||||
// Listen for Keplr wallet switches so we can refresh the Keplr & CosmWasm info
|
||||
useEffect(() => {
|
||||
const params: [string, () => void] = [
|
||||
'keplr_keystorechange',
|
||||
() => {
|
||||
setIsLoading(true)
|
||||
|
||||
wallet
|
||||
.refreshUser()
|
||||
.then(cosm.init)
|
||||
.finally(() => {
|
||||
setWalletAddress(wallet.getAccount().address)
|
||||
setIsLoading(false)
|
||||
const { disconnect } = useDisconnect({
|
||||
onSuccess: clearMnemonic,
|
||||
})
|
||||
const { data: signingClient } = useCosmWasmSigningClient()
|
||||
const { executeContract } = useExecuteContract({
|
||||
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
|
||||
onError: (error: any) => {
|
||||
setLoading(false)
|
||||
showError(error.message)
|
||||
},
|
||||
]
|
||||
})
|
||||
const walletAddress = data?.bech32Address ?? ''
|
||||
|
||||
window.addEventListener(...params)
|
||||
|
||||
return () => window.removeEventListener(...params)
|
||||
}, [])
|
||||
const { data: encryptedBalance } = useQuerySmart<string, any>(
|
||||
data && {
|
||||
address: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
|
||||
queryMsg: contractMessageBuilders.getBalance(walletAddress),
|
||||
},
|
||||
)
|
||||
|
||||
// Set the current balance for the wallet. Whenever the wallet changes, we retrieve its balance
|
||||
useEffect(() => {
|
||||
cosm
|
||||
.queryTransferContract({
|
||||
messageBuilder: () => contractMessageBuilders.getBalance(walletAddress),
|
||||
})
|
||||
.then((data) => setBalance(retrieveBalance(wallet.decrypt(data))))
|
||||
}, [walletAddress])
|
||||
decrypt(encryptedBalance!)
|
||||
.then((data) => setBalance(retrieveBalance(data)))
|
||||
.catch((error: any) => showError(error.message))
|
||||
}, [encryptedBalance])
|
||||
|
||||
// Listen for the response event from the blockchain when requesting the current wallet new balance
|
||||
useEffect(() => {
|
||||
return wasmEventHandler(
|
||||
`execute._contract_address='${process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS}' AND wasm-store_balance.address='${walletAddress}'`,
|
||||
{
|
||||
next: (event) => {
|
||||
next: (event: any) => {
|
||||
console.log(event)
|
||||
if (!isEmpty(event?.events['wasm-store_balance.encrypted_balance'])) {
|
||||
setBalance(
|
||||
retrieveBalance(
|
||||
wallet.decrypt(
|
||||
decrypt(
|
||||
event.events['wasm-store_balance.encrypted_balance'][0],
|
||||
),
|
||||
),
|
||||
)
|
||||
).then((data) => {
|
||||
setLoading(false)
|
||||
setBalance(retrieveBalance(data))
|
||||
showSuccess('Balance updated correctly')
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [walletAddress])
|
||||
|
||||
// Request the current wallet new balance calling the transfer contract
|
||||
async function requestBalance(): Promise<void> {
|
||||
let result
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const response = await cosm.executeTransferContract({
|
||||
messageBuilder: () =>
|
||||
contractMessageBuilders.requestBalance(wallet.getKeypair().pubkey),
|
||||
async function requestBalance() {
|
||||
setLoading(true)
|
||||
executeContract({
|
||||
signingClient: signingClient!,
|
||||
msg: contractMessageBuilders.requestBalance(
|
||||
(await getEphemeralKeypair()).pubkey,
|
||||
),
|
||||
})
|
||||
console.log(response)
|
||||
setIsLoading(false)
|
||||
|
||||
result = {
|
||||
success: true,
|
||||
messages: ['woo!'],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setIsLoading(false)
|
||||
result = {
|
||||
success: false,
|
||||
messages: ['Something went wrong'],
|
||||
}
|
||||
} finally {
|
||||
setRequestBalanceResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -142,11 +122,9 @@ export default function Home() {
|
|||
flex-col
|
||||
items-center
|
||||
justify-center
|
||||
bg-[url(/images/moroccan-flower.png)]
|
||||
p-12
|
||||
`}
|
||||
>
|
||||
<Notifications formActionResponse={requestBalanceResult} />
|
||||
<div
|
||||
className={tw`
|
||||
flex
|
||||
|
@ -154,40 +132,41 @@ export default function Home() {
|
|||
gap-2
|
||||
divide-y
|
||||
rounded-md
|
||||
border
|
||||
border-black/20
|
||||
bg-white
|
||||
p-5
|
||||
py-3
|
||||
shadow-2xl
|
||||
shadow-md
|
||||
outline
|
||||
outline-1
|
||||
outline-black/5
|
||||
`}
|
||||
>
|
||||
<div className="flex w-full justify-between">
|
||||
<span className="font-bold">Balance:</span>
|
||||
{!loading ? (
|
||||
<span className="font-bold">{formatAmount(balance)}</span>
|
||||
</div>
|
||||
|
||||
<StyledText
|
||||
className="bg-blue-500 font-bold"
|
||||
variant="button.primary"
|
||||
as="button"
|
||||
disabled={isLoading}
|
||||
onClick={requestBalance}
|
||||
>
|
||||
{!isLoading ? (
|
||||
<Icon name="building-columns" />
|
||||
) : (
|
||||
<div className="animate-spin">
|
||||
<Icon name="spinner" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StyledText
|
||||
className="justify-start font-bold"
|
||||
variant="button.primary"
|
||||
as="button"
|
||||
disabled={loading}
|
||||
onClick={requestBalance}
|
||||
>
|
||||
<Icon name="building-columns" />
|
||||
Get Balance
|
||||
</StyledText>
|
||||
|
||||
<div className="my-1 w-full border-black/20"></div>
|
||||
<div className="my-1 w-full border-black/25"></div>
|
||||
|
||||
<StyledText
|
||||
className="w-full bg-emerald-500 font-bold"
|
||||
className="w-full justify-start bg-emerald-500"
|
||||
variant="button.primary"
|
||||
onClick={() => setIsDepositModalOpen(true)}
|
||||
>
|
||||
|
@ -196,20 +175,40 @@ export default function Home() {
|
|||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.primary"
|
||||
className="w-full font-bold"
|
||||
className="w-full justify-start bg-violet-500"
|
||||
onClick={() => setIsTransferModalOpen(true)}
|
||||
>
|
||||
<Icon name="arrows-left-right" />
|
||||
Transfer
|
||||
</StyledText>
|
||||
<StyledText
|
||||
className="w-full bg-amber-500 font-bold"
|
||||
className="w-full justify-start bg-amber-500"
|
||||
variant="button.primary"
|
||||
onClick={() => setIsWithdrawModalOpen(true)}
|
||||
>
|
||||
<Icon name="money-bills-simple" />
|
||||
Withdraw
|
||||
</StyledText>
|
||||
<div className="my-1 w-full border-black/25"></div>
|
||||
<StyledText
|
||||
className="w-full justify-start"
|
||||
variant="button.secondary"
|
||||
onClick={() => {
|
||||
const res = confirm(
|
||||
'Disconnecting your account will remove your mnemonic and private key and you will lose access to your data unless you have them backed up. Are you sure you want to continue?',
|
||||
)
|
||||
|
||||
if (!res) {
|
||||
return
|
||||
}
|
||||
|
||||
useGlobalState.getState().setLoading(true)
|
||||
disconnect()
|
||||
}}
|
||||
>
|
||||
<Icon name="door-open" />
|
||||
Disconnect
|
||||
</StyledText>
|
||||
</div>
|
||||
|
||||
<DepositModalWindow
|
|
@ -1,9 +1,9 @@
|
|||
import { App } from '@/components'
|
||||
import type { Metadata } from 'next'
|
||||
import { Raleway } from 'next/font/google'
|
||||
import Script from 'next/script'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
import './globals.css'
|
||||
import Root from '@/components/Root'
|
||||
|
||||
const bodyFont = Raleway({ subsets: ['latin'], variable: '--font-raleway' })
|
||||
|
||||
|
@ -46,20 +46,15 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="/favicon.png"
|
||||
type="image/png"
|
||||
/>
|
||||
<Script src="https://kit.fontawesome.com/ddaf2d7713.js" />
|
||||
</head>
|
||||
<body
|
||||
className={twMerge(
|
||||
`
|
||||
overflow-x-hidden
|
||||
bg-appBgColor
|
||||
text-textColor
|
||||
`,
|
||||
bodyFont.className,
|
||||
bodyFont.variable,
|
||||
)}
|
||||
>
|
||||
<App>{children}</App>
|
||||
<body className={(bodyFont.className, 'bg-gray-500/25')}>
|
||||
<Root>{children}</Root>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
18
apps/transfers/frontend/src/app/not-found.tsx
Normal file
18
apps/transfers/frontend/src/app/not-found.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import Link from 'next/link'
|
||||
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center gap-4 p-24">
|
||||
<p>Page not found ❌</p>
|
||||
<StyledText
|
||||
as={Link}
|
||||
href="/"
|
||||
variant="button.secondary"
|
||||
>
|
||||
Go Home
|
||||
</StyledText>
|
||||
</main>
|
||||
)
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { wallet } from '@/lib/wallet'
|
||||
import { cosm } from '@/lib/cosm'
|
||||
import { LoadingSpinner } from './LoadingSpinner'
|
||||
|
||||
// Root component
|
||||
export function App({ children }: { children: ReactNode }) {
|
||||
const [isInit, setIsInit] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Inititalize Keplr and CosmWasm wrappers
|
||||
wallet
|
||||
.init()
|
||||
.then(cosm.init)
|
||||
.then(() => setIsInit(true))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingSpinner isLoading={!isInit} />
|
||||
{isInit && children}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,70 +1,75 @@
|
|||
'use client'
|
||||
|
||||
import { ChangeEvent, useActionState, useState } from 'react'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { useCosmWasmSigningClient, useExecuteContract } from 'graz'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
|
||||
import { Notifications } from '@/components/Notifications'
|
||||
import { StyledBox } from '@/components/StyledBox'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
|
||||
import { cosm } from '@/lib/cosm'
|
||||
import { tw } from '@/lib/tw'
|
||||
import { FormActionResponse } from '@/lib/types'
|
||||
import chain from '@/config/chain'
|
||||
import { showError, showSuccess } from '@/lib/notifications'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
// Deposit the specified amount calling the Transfer contract
|
||||
async function handleDeposit(
|
||||
_: FormActionResponse,
|
||||
formData: FormData,
|
||||
): Promise<FormActionResponse> {
|
||||
const amount = String(formData.get('amount'))
|
||||
|
||||
try {
|
||||
const result = await cosm.executeTransferContract({
|
||||
messageBuilder: contractMessageBuilders.deposit,
|
||||
fundsAmount: amount,
|
||||
export function DepositModalWindow(props: ModalWindowProps) {
|
||||
const [amount, setAmount] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { data: signingClient } = useCosmWasmSigningClient()
|
||||
const { executeContract } = useExecuteContract({
|
||||
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
|
||||
onSuccess: (data) => {
|
||||
console.log(data)
|
||||
setLoading(false)
|
||||
showSuccess('Deposit transaction sent successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false)
|
||||
showError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
// Deposit the specified amount calling the Transfer contract
|
||||
function handleDeposit() {
|
||||
setError('')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: ['woo!'],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (amount <= 0) {
|
||||
setError('Amount should be greater than zero.')
|
||||
|
||||
return {
|
||||
success: false,
|
||||
messages: ['Something went wrong'],
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function DepositModalWindow({
|
||||
isOpen,
|
||||
onClose,
|
||||
...otherProps
|
||||
}: ModalWindowProps) {
|
||||
const [amount, setAmount] = useState(0)
|
||||
const [formActionResponse, formAction, isLoading] = useActionState(
|
||||
handleDeposit,
|
||||
null,
|
||||
)
|
||||
setLoading(true)
|
||||
executeContract({
|
||||
signingClient: signingClient!,
|
||||
msg: contractMessageBuilders.deposit(),
|
||||
funds: [
|
||||
{
|
||||
denom: chain.currencies[0].coinMinimalDenom,
|
||||
amount: String(amount),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalWindow
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
{...otherProps}
|
||||
disableClosing={loading}
|
||||
{...props}
|
||||
>
|
||||
<LoadingSpinner isLoading={isLoading} />
|
||||
<LoadingSpinner isLoading={loading} />
|
||||
|
||||
<ModalWindow.Title className="bg-emerald-500">Deposit</ModalWindow.Title>
|
||||
<ModalWindow.Title className="bg-emerald-500">
|
||||
<Icon name="piggy-bank" /> Deposit
|
||||
</ModalWindow.Title>
|
||||
|
||||
<form action={formAction}>
|
||||
<ModalWindow.Body className="space-y-3">
|
||||
<Notifications formActionResponse={formActionResponse} />
|
||||
{!isEmpty(error) && (
|
||||
<div className="font-bold text-red-500">{error}</div>
|
||||
)}
|
||||
|
||||
<StyledBox
|
||||
as="input"
|
||||
|
@ -91,17 +96,17 @@ export function DepositModalWindow({
|
|||
className="bg-emerald-500"
|
||||
disabled={amount === 0}
|
||||
variant="button.primary"
|
||||
onClick={handleDeposit}
|
||||
>
|
||||
Deposit
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={onClose}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Cancel
|
||||
</StyledText>
|
||||
</ModalWindow.Buttons>
|
||||
</form>
|
||||
</ModalWindow>
|
||||
)
|
||||
}
|
||||
|
|
77
apps/transfers/frontend/src/components/EnterSeedModal.tsx
Normal file
77
apps/transfers/frontend/src/components/EnterSeedModal.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { EnglishMnemonic } from '@cosmjs/crypto'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { saveMnemonic } from '@/lib/ephemeralKeypair'
|
||||
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
|
||||
import { StyledBox } from './StyledBox'
|
||||
import { StyledText } from './StyledText'
|
||||
import { useGlobalState } from '@/state/useGlobalState'
|
||||
|
||||
export function EnterSeedModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
...otherProps
|
||||
}: ModalWindowProps) {
|
||||
const router = useRouter()
|
||||
const [mnemonic, setMnemonic] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submitSeed = () => {
|
||||
try {
|
||||
useGlobalState.getState().setLoading(true)
|
||||
setError('')
|
||||
const englishMnemonic = new EnglishMnemonic(mnemonic)
|
||||
saveMnemonic(englishMnemonic.toString())
|
||||
router.replace('/dashboard')
|
||||
} catch (err: any) {
|
||||
useGlobalState.getState().setLoading(false)
|
||||
setError(err.message)
|
||||
if (err.message !== 'Invalid mnemonic format') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalWindow
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
{...otherProps}
|
||||
>
|
||||
<ModalWindow.Title>Enter recovery phrase</ModalWindow.Title>
|
||||
<ModalWindow.Body className="space-y-3">
|
||||
{!isEmpty(error) && (
|
||||
<div className="font-bold text-red-500">{error}</div>
|
||||
)}
|
||||
<StyledBox
|
||||
as="input"
|
||||
min={0}
|
||||
value={mnemonic}
|
||||
variant="input"
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||
setMnemonic(event.target.value)
|
||||
}
|
||||
/>
|
||||
</ModalWindow.Body>
|
||||
<ModalWindow.Buttons>
|
||||
<StyledText
|
||||
as="button"
|
||||
variant="button.primary"
|
||||
onClick={submitSeed}
|
||||
>
|
||||
Continue
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</StyledText>
|
||||
</ModalWindow.Buttons>
|
||||
</ModalWindow>
|
||||
)
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
export { Icon } from "./Icon"
|
||||
export { Icon } from './Icon'
|
||||
export type {
|
||||
BrandIconName,
|
||||
IconName,
|
||||
|
@ -6,4 +6,4 @@ export type {
|
|||
IconRotationOption,
|
||||
IconVariant,
|
||||
RegularIconName,
|
||||
} from "./types"
|
||||
} from './types'
|
||||
|
|
|
@ -15,7 +15,6 @@ export function LoadingSpinner({
|
|||
<div
|
||||
className={twMerge(
|
||||
`
|
||||
pointer-events-none
|
||||
absolute
|
||||
bottom-0
|
||||
left-0
|
||||
|
@ -25,7 +24,7 @@ export function LoadingSpinner({
|
|||
flex
|
||||
items-center
|
||||
justify-center
|
||||
bg-appBgColor
|
||||
bg-gray-500/25
|
||||
transition-all
|
||||
`,
|
||||
isLoading
|
||||
|
@ -34,6 +33,7 @@ export function LoadingSpinner({
|
|||
`
|
||||
: `
|
||||
opacity-0
|
||||
pointer-events-none
|
||||
`,
|
||||
className,
|
||||
)}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { twMerge } from 'tailwind-merge'
|
|||
import { classNames } from './classNames'
|
||||
|
||||
export interface ModalWindowProps extends ComponentProps<'div'> {
|
||||
disableClosing?: boolean
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
@ -13,6 +14,7 @@ export interface ModalWindowProps extends ComponentProps<'div'> {
|
|||
export function ModalWindow({
|
||||
children,
|
||||
className,
|
||||
disableClosing,
|
||||
isOpen,
|
||||
onClose,
|
||||
...otherProps
|
||||
|
@ -69,8 +71,8 @@ export function ModalWindow({
|
|||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (!disableClosing && isOpen) {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
handleClose()
|
||||
}
|
||||
|
@ -78,11 +80,9 @@ export function ModalWindow({
|
|||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}
|
||||
}, [isOpen, onClose])
|
||||
}, [disableClosing, isOpen, onClose])
|
||||
|
||||
if (!isClient) {
|
||||
return null
|
||||
|
@ -92,9 +92,8 @@ export function ModalWindow({
|
|||
<>
|
||||
<div
|
||||
className={classNames.backdrop({ modalState })}
|
||||
onClick={handleClose}
|
||||
{...(!disableClosing && { onClick: handleClose })}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={twMerge(classNames.container({ modalState }), className)}
|
||||
ref={windowContentsContainerRef}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { tw } from "@/lib/tw";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { tw } from '@/lib/tw'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export const classNames = {
|
||||
backdrop: ({ modalState = 'closed' }) => twMerge(
|
||||
backdrop: ({ modalState = 'closed' }) =>
|
||||
twMerge(
|
||||
`
|
||||
fixed
|
||||
left-0
|
||||
|
@ -13,49 +14,49 @@ export const classNames = {
|
|||
z-10
|
||||
duration-500
|
||||
`,
|
||||
(modalState === 'opening' || modalState === 'open')
|
||||
?
|
||||
`
|
||||
modalState === 'opening' || modalState === 'open'
|
||||
? `
|
||||
opacity-100
|
||||
backdrop-blur-md
|
||||
pointer-events-auto
|
||||
bg-shadedBgColor/20
|
||||
`
|
||||
:
|
||||
`
|
||||
: `
|
||||
opacity-0
|
||||
backdrop-blur-none
|
||||
pointer-events-none
|
||||
bg-transparent
|
||||
`
|
||||
`,
|
||||
),
|
||||
|
||||
container: ({ modalState = 'closed' }) => twMerge(
|
||||
container: ({ modalState = 'closed' }) =>
|
||||
twMerge(
|
||||
`
|
||||
fixed
|
||||
left-1/2
|
||||
top-1/2
|
||||
bg-appBgColor
|
||||
rounded-lg
|
||||
rounded-md
|
||||
overflow-hidden
|
||||
-translate-x-1/2
|
||||
z-10
|
||||
duration-500
|
||||
min-w-64
|
||||
shadow-xl
|
||||
shadow-md
|
||||
outline
|
||||
outline-1
|
||||
outline-black/5
|
||||
`,
|
||||
(modalState === 'opening' || modalState === 'open')
|
||||
?
|
||||
`
|
||||
modalState === 'opening' || modalState === 'open'
|
||||
? `
|
||||
opacity-100
|
||||
-translate-y-1/2
|
||||
`
|
||||
:
|
||||
`
|
||||
: `
|
||||
opacity-0
|
||||
pointer-events-none
|
||||
-translate-y-full
|
||||
`
|
||||
`,
|
||||
),
|
||||
|
||||
header: tw`
|
||||
|
@ -75,5 +76,5 @@ export const classNames = {
|
|||
flex-row-reverse
|
||||
gap-1
|
||||
p-3
|
||||
`
|
||||
`,
|
||||
}
|
||||
|
|
|
@ -1,140 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
import { FormActionResponse } from '@/lib/types'
|
||||
import { uniqueId } from 'lodash'
|
||||
import { ComponentProps, useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { classNames } from './classNames'
|
||||
|
||||
interface NotificationsProps extends Omit<ComponentProps<'ul'>, 'children'> {
|
||||
formActionResponse: FormActionResponse | undefined
|
||||
}
|
||||
|
||||
type NotificationState = 'entering' | 'entered' | 'exiting' | 'exited'
|
||||
|
||||
export function Notifications({
|
||||
className,
|
||||
formActionResponse,
|
||||
...otherProps
|
||||
}: NotificationsProps) {
|
||||
const [storedMessages, setStoredMessages] = useState<
|
||||
{ id: number; message: string }[]
|
||||
>([])
|
||||
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
const { success, messages } = formActionResponse || {
|
||||
success: false,
|
||||
messages: [],
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (messages) {
|
||||
setStoredMessages((currentStoredMessages) => [
|
||||
...currentStoredMessages,
|
||||
...messages.map((message) => ({
|
||||
id: Number(uniqueId()),
|
||||
message,
|
||||
})),
|
||||
])
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true)
|
||||
}, [])
|
||||
|
||||
function handleDismissMessage(id: number) {
|
||||
setStoredMessages((currentStoredMessages) =>
|
||||
currentStoredMessages.filter((message) => message.id !== id),
|
||||
)
|
||||
}
|
||||
|
||||
if (!isClient) {
|
||||
return null
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className={classNames.backdrop({
|
||||
hasMessages: storedMessages.length > 0,
|
||||
success,
|
||||
})}
|
||||
/>
|
||||
{messages && (
|
||||
<ul
|
||||
className={twMerge(classNames.container, className)}
|
||||
{...otherProps}
|
||||
>
|
||||
{storedMessages.map(({ id, message }, index) => (
|
||||
<Notifications.Notification
|
||||
index={index}
|
||||
key={id}
|
||||
message={message}
|
||||
success={success}
|
||||
onDismiss={handleDismissMessage.bind(null, id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
Notifications.Notification = function Notification({
|
||||
index,
|
||||
message,
|
||||
success,
|
||||
onDismiss,
|
||||
...otherProps
|
||||
}: ComponentProps<'li'> & {
|
||||
index: number
|
||||
message: string
|
||||
success: boolean
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
const [state, setState] = useState<NotificationState>('entering')
|
||||
|
||||
function handleAnimationEnd() {
|
||||
if (state === 'entering') {
|
||||
setState('entered')
|
||||
}
|
||||
|
||||
if (state === 'exiting') {
|
||||
setState('exited')
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
state !== 'exited' && (
|
||||
<li
|
||||
className={classNames.notificationContainer({ state })}
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
}}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={classNames.notificationSurface({ success })}>
|
||||
<Icon name={success ? 'circle-check' : 'circle-exclamation'} />
|
||||
<div>{message}</div>
|
||||
<div>
|
||||
<StyledText
|
||||
as="button"
|
||||
variant="button.tool"
|
||||
onClick={() => setState('exiting')}
|
||||
>
|
||||
<Icon name="xmark" />
|
||||
</StyledText>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
)
|
||||
}
|
|
@ -1,90 +0,0 @@
|
|||
import { tw } from '@/lib/tw'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export const classNames = {
|
||||
backdrop: ({ hasMessages = false, success = false }) =>
|
||||
twMerge(
|
||||
`
|
||||
fixed
|
||||
bottom-0
|
||||
right-0
|
||||
h-[20vh]
|
||||
w-[50vw]
|
||||
bg-gradient-to-tl
|
||||
via-transparent
|
||||
to-transparent
|
||||
opacity-0
|
||||
transition-opacity
|
||||
duration-500
|
||||
z-10
|
||||
`,
|
||||
success ? 'from-green-400/50' : 'from-red-400/50',
|
||||
hasMessages &&
|
||||
`
|
||||
opacity-100
|
||||
`,
|
||||
),
|
||||
|
||||
container: tw`
|
||||
fixed
|
||||
bottom-0
|
||||
right-0
|
||||
flex
|
||||
flex-col-reverse
|
||||
gap-2
|
||||
p-6
|
||||
z-20
|
||||
`,
|
||||
|
||||
notificationContainer: ({ state = 'entering' }) =>
|
||||
twMerge(
|
||||
`
|
||||
w-[20rem]
|
||||
overflow-hidden
|
||||
transition-all
|
||||
`,
|
||||
state === 'entering' &&
|
||||
`
|
||||
animate-animateInX
|
||||
`,
|
||||
state === 'entered' &&
|
||||
`
|
||||
opacity-100
|
||||
shadow-md
|
||||
`,
|
||||
state === 'exiting' &&
|
||||
`
|
||||
animate-animateOutX
|
||||
`,
|
||||
state === 'exited' &&
|
||||
`
|
||||
hidden
|
||||
`,
|
||||
),
|
||||
|
||||
notificationSurface: ({ success = false }) =>
|
||||
twMerge(
|
||||
`
|
||||
grid
|
||||
w-full
|
||||
grid-cols-[auto,1fr,auto]
|
||||
gap-3
|
||||
rounded-md
|
||||
border-2
|
||||
px-3
|
||||
py-3
|
||||
text-sm
|
||||
text-white
|
||||
transition-all
|
||||
`,
|
||||
success
|
||||
? `
|
||||
border-green-400
|
||||
bg-green-500
|
||||
`
|
||||
: `
|
||||
border-red-400
|
||||
bg-red-500
|
||||
`,
|
||||
),
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { Notifications } from './Notifications'
|
37
apps/transfers/frontend/src/components/Root/App.tsx
Normal file
37
apps/transfers/frontend/src/components/Root/App.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { SnackbarProvider } from 'notistack'
|
||||
|
||||
import chain from '@/config/chain'
|
||||
import Middleware from './Middleware'
|
||||
import GrazWrapper from './GrazWrapper'
|
||||
import { LoadingWrapper } from './LoadingWrapper'
|
||||
|
||||
// Method to skip first render because Graz initial wallet status is 'disconnected' and NOT 'reconnecting'
|
||||
// With this, we let the middleware initialize with the 'reconnecting' status
|
||||
const SkipFirstRender = ({ children }: React.PropsWithChildren) => {
|
||||
const [isNotFirstRender, setIsNotFirstRender] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsNotFirstRender(true)
|
||||
}, [])
|
||||
|
||||
return isNotFirstRender && children
|
||||
}
|
||||
|
||||
// Global App stuff definition
|
||||
export default function App({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<GrazWrapper chains={[chain]}>
|
||||
<SkipFirstRender>
|
||||
<Middleware>{children}</Middleware>
|
||||
</SkipFirstRender>
|
||||
<LoadingWrapper />
|
||||
<SnackbarProvider
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
preventDuplicate
|
||||
/>
|
||||
</GrazWrapper>
|
||||
)
|
||||
}
|
30
apps/transfers/frontend/src/components/Root/GrazWrapper.tsx
Normal file
30
apps/transfers/frontend/src/components/Root/GrazWrapper.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { GrazProvider, WalletType } from 'graz'
|
||||
import { ChainInfo } from '@keplr-wallet/types'
|
||||
|
||||
export default function GrazWrapper({
|
||||
chains,
|
||||
children,
|
||||
}: {
|
||||
chains: ChainInfo[]
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<GrazProvider
|
||||
grazOptions={{
|
||||
chains,
|
||||
defaultWallet: WalletType.KEPLR,
|
||||
chainsConfig: chains.reduce(
|
||||
(acc, curr) => ({
|
||||
...acc,
|
||||
[curr.chainId]: {
|
||||
gas: { denom: curr.feeCurrencies[0].coinDenom, price: '0' },
|
||||
},
|
||||
}),
|
||||
{},
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</GrazProvider>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { useGlobalState } from '@/state/useGlobalState'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
|
||||
export const LoadingWrapper = () => {
|
||||
const loading = useGlobalState((state) => state.loading)
|
||||
|
||||
return <LoadingSpinner isLoading={loading} />
|
||||
}
|
77
apps/transfers/frontend/src/components/Root/Middleware.tsx
Normal file
77
apps/transfers/frontend/src/components/Root/Middleware.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useAccount } from 'graz'
|
||||
|
||||
import { getMnemonic } from '@/lib/ephemeralKeypair'
|
||||
import { useGlobalState } from '@/state/useGlobalState'
|
||||
|
||||
enum SetupPhases {
|
||||
// User is not connected to Keplr Wallet
|
||||
WALLET_CONNECTION,
|
||||
// No mnemonic in local storage
|
||||
MNEMONIC_CREATION,
|
||||
// User is connected and has mnemonic
|
||||
FINISHED_SETUP,
|
||||
}
|
||||
|
||||
const loginRoutes = ['/', '/set-seed']
|
||||
const setupRoutesMapping: Record<SetupPhases, string> = {
|
||||
[SetupPhases.WALLET_CONNECTION]: '/',
|
||||
[SetupPhases.MNEMONIC_CREATION]: '/set-seed',
|
||||
[SetupPhases.FINISHED_SETUP]: '',
|
||||
}
|
||||
|
||||
// App routing middleware
|
||||
// NOTE: Cannot use Nextjs middleware file because browser info is required
|
||||
export default function Middleware({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { status, isDisconnected, isReconnecting } = useAccount()
|
||||
const mnemonic = getMnemonic()
|
||||
const isAnonPage = loginRoutes.includes(pathname)
|
||||
const targetRoute =
|
||||
setupRoutesMapping[
|
||||
((): SetupPhases => {
|
||||
if (isDisconnected) {
|
||||
return SetupPhases.WALLET_CONNECTION
|
||||
} else if (!mnemonic) {
|
||||
return SetupPhases.MNEMONIC_CREATION
|
||||
} else {
|
||||
return SetupPhases.FINISHED_SETUP
|
||||
}
|
||||
})()
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (isReconnecting) {
|
||||
return
|
||||
}
|
||||
|
||||
let redirectTo = targetRoute
|
||||
|
||||
if (!redirectTo && isAnonPage) {
|
||||
redirectTo = '/dashboard'
|
||||
}
|
||||
|
||||
// Redirect if the path is not the correct one
|
||||
if (redirectTo && redirectTo !== pathname) {
|
||||
useGlobalState.getState().setLoading(true)
|
||||
router.replace(redirectTo)
|
||||
} else {
|
||||
useGlobalState.getState().setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, pathname])
|
||||
|
||||
return (
|
||||
!isReconnecting &&
|
||||
(targetRoute === pathname || (!isAnonPage && !targetRoute)) &&
|
||||
children
|
||||
)
|
||||
}
|
11
apps/transfers/frontend/src/components/Root/index.tsx
Normal file
11
apps/transfers/frontend/src/components/Root/index.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
'use client'
|
||||
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// Dynamic import for app so no Graz code prerenders in server
|
||||
// This avoids errors on first render
|
||||
const App = dynamic(() => import('./App'), { ssr: false })
|
||||
|
||||
export default function Root({ children }: React.PropsWithChildren) {
|
||||
return <App>{children}</App>
|
||||
}
|
|
@ -19,6 +19,7 @@ export const classNames = {
|
|||
border-borderColor
|
||||
!outline-accentColor
|
||||
!ring-accentColor
|
||||
accent-accentColor
|
||||
checked:text-accentColor
|
||||
`,
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ const buttonClassNames = tw`
|
|||
items-center
|
||||
justify-center
|
||||
gap-2
|
||||
font-bold
|
||||
transition-all
|
||||
hover:scale-105
|
||||
disabled:pointer-events-none
|
||||
|
@ -25,41 +26,17 @@ const headingClassNames = tw`
|
|||
export const classNames = {
|
||||
'button.primary': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
whitespace-nowrap
|
||||
rounded-md
|
||||
bg-accentColor
|
||||
px-3
|
||||
py-1
|
||||
text-white
|
||||
`,
|
||||
`whitespace-nowrap rounded-md bg-accentColor px-3 py-1 text-white`,
|
||||
),
|
||||
|
||||
'button.secondary': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
relative
|
||||
z-10
|
||||
whitespace-nowrap
|
||||
rounded-md
|
||||
border
|
||||
bg-white
|
||||
px-3
|
||||
py-1
|
||||
text-textColor
|
||||
backdrop-blur-sm
|
||||
`,
|
||||
`relative z-10 whitespace-nowrap rounded-md border bg-gray-400 px-3 py-1 text-white backdrop-blur-sm`,
|
||||
),
|
||||
|
||||
'button.icon': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
size-10
|
||||
rounded-full
|
||||
bg-shadedBgColor
|
||||
hover:bg-accentColor
|
||||
hover:text-appBgColor
|
||||
`,
|
||||
`size-10 rounded-full bg-shadedBgColor hover:bg-accentColor hover:text-appBgColor`,
|
||||
),
|
||||
|
||||
'button.tool': tw`
|
||||
|
@ -80,33 +57,13 @@ export const classNames = {
|
|||
text-fadedTextColor
|
||||
`,
|
||||
|
||||
'h1': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-5xl
|
||||
`,
|
||||
),
|
||||
'h1': twJoin(headingClassNames, `text-5xl`),
|
||||
|
||||
'h2': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-4xl
|
||||
`,
|
||||
),
|
||||
'h2': twJoin(headingClassNames, `text-4xl`),
|
||||
|
||||
'h3': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-3xl
|
||||
`,
|
||||
),
|
||||
'h3': twJoin(headingClassNames, `text-3xl`),
|
||||
|
||||
'h4': twJoin(
|
||||
headingClassNames,
|
||||
`
|
||||
text-lg
|
||||
`,
|
||||
),
|
||||
'h4': twJoin(headingClassNames, `text-lg`),
|
||||
|
||||
'label': tw`
|
||||
text-sm
|
||||
|
@ -115,25 +72,12 @@ export const classNames = {
|
|||
|
||||
'link': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
text-accentColor
|
||||
underline
|
||||
underline-offset-4
|
||||
transition-all
|
||||
hover:underline-offset-4
|
||||
`,
|
||||
`text-accentColor underline underline-offset-4 transition-all hover:underline-offset-4`,
|
||||
),
|
||||
|
||||
'link.subtle': twMerge(
|
||||
buttonClassNames,
|
||||
`
|
||||
text-inherit
|
||||
underline-offset-4
|
||||
transition-all
|
||||
hover:text-accentColor
|
||||
hover:underline
|
||||
hover:underline-offset-4
|
||||
`,
|
||||
`text-inherit underline-offset-4 transition-all hover:text-accentColor hover:underline hover:underline-offset-4`,
|
||||
),
|
||||
|
||||
'logo': tw`
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
'use client'
|
||||
|
||||
import { PublicKey, encrypt } from 'eciesjs'
|
||||
import { ChangeEvent, useActionState, useState } from 'react'
|
||||
import invariant from 'tiny-invariant'
|
||||
import { ChangeEvent, useState } from 'react'
|
||||
import { useAccount, useCosmWasmSigningClient, useExecuteContract } from 'graz'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
|
||||
import { Notifications } from '@/components/Notifications'
|
||||
import { StyledBox } from '@/components/StyledBox'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
import { cosm } from '@/lib/cosm'
|
||||
import { FormActionResponse } from '@/lib/types'
|
||||
import { wallet } from '@/lib/wallet'
|
||||
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
|
||||
import chain from '@/config/chain'
|
||||
import { tw } from '@/lib/tw'
|
||||
import { showError, showSuccess } from '@/lib/notifications'
|
||||
import { isValidAddress } from '@/lib/isValidAddress'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
// Encrypt the transfer data using the enclave public key
|
||||
function encryptMsg(data: {
|
||||
|
@ -19,11 +22,6 @@ function encryptMsg(data: {
|
|||
receiver: string
|
||||
amount: string
|
||||
}): string {
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY,
|
||||
'NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY must be defined',
|
||||
)
|
||||
|
||||
// Create the public key from the hex
|
||||
const pubkey = PublicKey.fromHex(process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY)
|
||||
// Convert the data into a JSON string
|
||||
|
@ -37,68 +35,85 @@ function encryptMsg(data: {
|
|||
return encryptedState.toString('hex')
|
||||
}
|
||||
|
||||
// Transfer an amount between wallets calling the Transfer contract with an encrypted request
|
||||
async function handleTransfer(
|
||||
_: FormActionResponse,
|
||||
formData: FormData,
|
||||
): Promise<FormActionResponse> {
|
||||
const receiver = String(formData.get('receiver'))
|
||||
const amount = String(formData.get('amount'))
|
||||
|
||||
try {
|
||||
const encryptedMsg = encryptMsg({
|
||||
sender: wallet.getAccount().address,
|
||||
receiver,
|
||||
amount,
|
||||
})
|
||||
|
||||
const result = await cosm.executeTransferContract({
|
||||
messageBuilder: () => contractMessageBuilders.transfer(encryptedMsg),
|
||||
})
|
||||
console.log(result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: ['woo!'],
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
messages: ['Something went wrong'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function TransferModalWindow({
|
||||
isOpen,
|
||||
onClose,
|
||||
...otherProps
|
||||
}: ModalWindowProps) {
|
||||
export function TransferModalWindow(props: ModalWindowProps) {
|
||||
const [amount, setAmount] = useState(0)
|
||||
const [receiver, setRecipient] = useState('')
|
||||
const [formActionResponse, formAction, isLoading] = useActionState(
|
||||
handleTransfer,
|
||||
null,
|
||||
)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const { data: wallet } = useAccount()
|
||||
const { data: signingClient } = useCosmWasmSigningClient()
|
||||
const { executeContract } = useExecuteContract({
|
||||
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
|
||||
onSuccess: (data) => {
|
||||
console.log(data)
|
||||
setLoading(false)
|
||||
showSuccess('Transfer transaction sent successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false)
|
||||
showError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Transfer an amount between wallets calling the Transfer contract with an encrypted request
|
||||
function handleTransfer() {
|
||||
setError('')
|
||||
|
||||
if (!isValidAddress(receiver)) {
|
||||
setError('Invalid recipient address format.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (amount <= 0) {
|
||||
setError('Amount should be greater than zero.')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
const encryptedMsg = encryptMsg({
|
||||
sender: wallet?.bech32Address!,
|
||||
receiver: String(receiver),
|
||||
amount: String(amount),
|
||||
})
|
||||
|
||||
executeContract({
|
||||
signingClient: signingClient!,
|
||||
msg: contractMessageBuilders.transfer(encryptedMsg),
|
||||
funds: [
|
||||
{
|
||||
denom: chain.currencies[0].coinMinimalDenom,
|
||||
amount: String(amount),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalWindow
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
{...otherProps}
|
||||
disableClosing={loading}
|
||||
{...props}
|
||||
>
|
||||
<LoadingSpinner isLoading={isLoading} />
|
||||
<LoadingSpinner isLoading={loading} />
|
||||
|
||||
<ModalWindow.Title>Transfer</ModalWindow.Title>
|
||||
<ModalWindow.Title className="bg-violet-500">
|
||||
<Icon name="arrows-left-right" /> Transfer
|
||||
</ModalWindow.Title>
|
||||
|
||||
<form action={formAction}>
|
||||
<ModalWindow.Body className="space-y-3">
|
||||
<Notifications formActionResponse={formActionResponse} />
|
||||
{!isEmpty(error) && (
|
||||
<div className="font-bold text-red-500">{error}</div>
|
||||
)}
|
||||
|
||||
<StyledBox
|
||||
as="input"
|
||||
className={tw`
|
||||
focus:!border-violet-500
|
||||
focus:!outline-violet-500
|
||||
focus:!ring-violet-500
|
||||
`}
|
||||
placeholder="recipient address"
|
||||
type="text"
|
||||
variant="input"
|
||||
|
@ -111,6 +126,11 @@ export function TransferModalWindow({
|
|||
|
||||
<StyledBox
|
||||
as="input"
|
||||
className={tw`
|
||||
focus:!border-violet-500
|
||||
focus:!outline-violet-500
|
||||
focus:!ring-violet-500
|
||||
`}
|
||||
min={0}
|
||||
name="amount"
|
||||
placeholder="0.00"
|
||||
|
@ -126,19 +146,20 @@ export function TransferModalWindow({
|
|||
<ModalWindow.Buttons>
|
||||
<StyledText
|
||||
as="button"
|
||||
className="bg-violet-500"
|
||||
disabled={amount === 0}
|
||||
variant="button.primary"
|
||||
onClick={handleTransfer}
|
||||
>
|
||||
Transfer
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={onClose}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Cancel
|
||||
</StyledText>
|
||||
</ModalWindow.Buttons>
|
||||
</form>
|
||||
</ModalWindow>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,78 +1,70 @@
|
|||
'use client'
|
||||
import { useActionState } from 'react'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useCosmWasmSigningClient, useExecuteContract } from 'graz'
|
||||
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { ModalWindow, ModalWindowProps } from '@/components/ModalWindow'
|
||||
import { Notifications } from '@/components/Notifications'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
import { cosm } from '@/lib/cosm'
|
||||
import { FormActionResponse } from '@/lib/types'
|
||||
import { contractMessageBuilders } from '@/lib/contractMessageBuilders'
|
||||
import { showError, showSuccess } from '@/lib/notifications'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
// Withdraw all funds from the wallet balance calling the Transfer contract
|
||||
async function handleWithdraw(): Promise<FormActionResponse> {
|
||||
try {
|
||||
const result = await cosm.executeTransferContract({
|
||||
messageBuilder: contractMessageBuilders.withdraw,
|
||||
export function WithdrawModalWindow(props: ModalWindowProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { data: signingClient } = useCosmWasmSigningClient()
|
||||
const { executeContract } = useExecuteContract({
|
||||
contractAddress: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS!,
|
||||
onSuccess: (data) => {
|
||||
console.log(data)
|
||||
setLoading(false)
|
||||
showSuccess('Withdraw transaction sent successfully')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setLoading(false)
|
||||
showError(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
console.log(result)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: ['woo!'],
|
||||
// Withdraw all funds from the wallet balance calling the Transfer contract
|
||||
function handleWithdraw() {
|
||||
setLoading(true)
|
||||
executeContract({
|
||||
signingClient: signingClient!,
|
||||
msg: contractMessageBuilders.withdraw(),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
messages: ['Something went wrong'],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function WithdrawModalWindow({
|
||||
isOpen,
|
||||
onClose,
|
||||
...otherProps
|
||||
}: ModalWindowProps) {
|
||||
const [formActionResponse, formAction, isLoading] = useActionState(
|
||||
handleWithdraw,
|
||||
null,
|
||||
)
|
||||
|
||||
return (
|
||||
<ModalWindow
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
{...otherProps}
|
||||
disableClosing={loading}
|
||||
{...props}
|
||||
>
|
||||
<LoadingSpinner isLoading={isLoading} />
|
||||
<LoadingSpinner isLoading={loading} />
|
||||
|
||||
<ModalWindow.Title className="bg-amber-500">Withdraw</ModalWindow.Title>
|
||||
<ModalWindow.Title className="bg-amber-500">
|
||||
<Icon name="money-bills-simple" /> Withdraw
|
||||
</ModalWindow.Title>
|
||||
|
||||
<form action={formAction}>
|
||||
<ModalWindow.Body className="space-y-3">
|
||||
<Notifications formActionResponse={formActionResponse} />
|
||||
<p>This will return the entire remaining balance to your wallet.</p>
|
||||
<p>Withdraw the entire balance back to your wallet.</p>
|
||||
</ModalWindow.Body>
|
||||
<ModalWindow.Buttons>
|
||||
<StyledText
|
||||
as="button"
|
||||
className="bg-amber-500"
|
||||
variant="button.primary"
|
||||
onClick={handleWithdraw}
|
||||
>
|
||||
Withdraw
|
||||
</StyledText>
|
||||
<StyledText
|
||||
variant="button.secondary"
|
||||
onClick={onClose}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Cancel
|
||||
</StyledText>
|
||||
</ModalWindow.Buttons>
|
||||
</form>
|
||||
</ModalWindow>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
export { App } from './App'
|
||||
export { DepositModalWindow } from './DepositModalWindow'
|
||||
export { Icon } from './Icon'
|
||||
export { LoadingSpinner } from './LoadingSpinner'
|
||||
export { ModalWindow } from './ModalWindow'
|
||||
export { Notifications } from './Notifications'
|
||||
export { StyledBox } from './StyledBox'
|
||||
export { StyledText } from './StyledText'
|
||||
export { TransferModalWindow } from './TransferModalWindow'
|
||||
export { WithdrawModalWindow } from './WithdrawModalWindow'
|
19
apps/transfers/frontend/src/config/chain.ts
Normal file
19
apps/transfers/frontend/src/config/chain.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { ChainInfo } from '@keplr-wallet/types'
|
||||
|
||||
import { localWasm } from './chains/localWasm'
|
||||
import { localNeutron } from './chains/localNeutron'
|
||||
|
||||
const supportedChains: Record<string, ChainInfo> = {
|
||||
doWasm: {
|
||||
...localWasm,
|
||||
chainName: 'Digital Ocean Testchain',
|
||||
rpc: 'http://143.244.186.205:26657',
|
||||
rest: 'http://143.244.186.205:1317',
|
||||
},
|
||||
localNeutron,
|
||||
localWasm,
|
||||
}
|
||||
|
||||
const chain = supportedChains[process.env.NEXT_PUBLIC_TARGET_CHAIN!]
|
||||
|
||||
export default chain
|
|
@ -1,25 +1,11 @@
|
|||
import { ChainInfo } from '@keplr-wallet/types'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_CHAIN_ID,
|
||||
'NEXT_PUBLIC_CHAIN_ID must be defined',
|
||||
)
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
|
||||
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
|
||||
)
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_CHAIN_REST_URL,
|
||||
'NEXT_PUBLIC_CHAIN_REST_URL must be defined',
|
||||
)
|
||||
|
||||
// Testchain definition
|
||||
export const chain: ChainInfo = {
|
||||
rpc: process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
|
||||
rest: process.env.NEXT_PUBLIC_CHAIN_REST_URL,
|
||||
chainId: process.env.NEXT_PUBLIC_CHAIN_ID,
|
||||
chainName: 'Neutron Local Chain',
|
||||
// Neutron local chain definition
|
||||
export const localNeutron: ChainInfo = {
|
||||
chainId: 'testing',
|
||||
chainName: 'Local Neutron Testchain',
|
||||
rpc: 'http://localhost:26657',
|
||||
rest: 'http://localhost:1317',
|
||||
stakeCurrency: {
|
||||
coinDenom: 'NEUTRON',
|
||||
coinMinimalDenom: 'untrn',
|
42
apps/transfers/frontend/src/config/chains/localWasm.ts
Normal file
42
apps/transfers/frontend/src/config/chains/localWasm.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { ChainInfo } from '@keplr-wallet/types'
|
||||
|
||||
// Wasm local chain definition
|
||||
export const localWasm: ChainInfo = {
|
||||
chainId: 'testing',
|
||||
chainName: 'Local Wasm Testchain',
|
||||
rpc: 'http://localhost:26657',
|
||||
rest: 'http://localhost:1317',
|
||||
bech32Config: {
|
||||
bech32PrefixAccAddr: 'wasm',
|
||||
bech32PrefixAccPub: 'wasmpub',
|
||||
bech32PrefixValAddr: 'wasmvaloper',
|
||||
bech32PrefixValPub: 'wasmvaloperpub',
|
||||
bech32PrefixConsAddr: 'wasmvalcons',
|
||||
bech32PrefixConsPub: 'wasmvalconspub',
|
||||
},
|
||||
currencies: [
|
||||
{
|
||||
coinDenom: 'COSM',
|
||||
coinMinimalDenom: 'ucosm',
|
||||
coinDecimals: 6,
|
||||
},
|
||||
{
|
||||
coinDenom: 'ATOM',
|
||||
coinMinimalDenom: 'uatom',
|
||||
coinDecimals: 6,
|
||||
},
|
||||
],
|
||||
feeCurrencies: [
|
||||
{
|
||||
coinDenom: 'COSM',
|
||||
coinMinimalDenom: 'ucosm',
|
||||
coinDecimals: 6,
|
||||
},
|
||||
],
|
||||
stakeCurrency: {
|
||||
coinDenom: 'ATOM',
|
||||
coinMinimalDenom: 'uatom',
|
||||
coinDecimals: 6,
|
||||
},
|
||||
bip44: { coinType: 118 },
|
||||
}
|
17
apps/transfers/frontend/src/instrumentation.ts
Normal file
17
apps/transfers/frontend/src/instrumentation.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import invariant from 'tiny-invariant'
|
||||
|
||||
export async function register() {
|
||||
// Ensure all required env vars are set before starting the server
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY,
|
||||
'NEXT_PUBLIC_ENCLAVE_PUBLIC_KEY environment variable must be defined',
|
||||
)
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_TARGET_CHAIN,
|
||||
'NEXT_PUBLIC_TARGET_CHAIN environment variable must be defined',
|
||||
)
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
|
||||
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS environment variable must be defined',
|
||||
)
|
||||
}
|
|
@ -1,96 +0,0 @@
|
|||
import { toUtf8 } from '@cosmjs/encoding'
|
||||
import { Registry } from '@cosmjs/proto-signing'
|
||||
import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate'
|
||||
import { coins } from '@cosmjs/stargate'
|
||||
import { MsgExecuteContract } from 'cosmjs-types/cosmwasm/wasm/v1/tx'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { wallet } from './wallet'
|
||||
|
||||
const typeUrl = '/cosmwasm.wasm.v1.MsgExecuteContract'
|
||||
const registry = new Registry([[typeUrl, MsgExecuteContract]])
|
||||
|
||||
// Cosm variables declaration. They will be set upon initialization.
|
||||
let signingCosmClient: SigningCosmWasmClient;
|
||||
|
||||
|
||||
|
||||
// Setup the CosmWasm client.
|
||||
const init = async () => {
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
|
||||
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
|
||||
)
|
||||
|
||||
|
||||
// Initialize Cosm client.
|
||||
signingCosmClient = await SigningCosmWasmClient.connectWithSigner(
|
||||
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
|
||||
wallet.getSigner(),
|
||||
{ registry },
|
||||
)
|
||||
|
||||
}
|
||||
// Transfer contract execution message
|
||||
const executeTransferContract = ({
|
||||
messageBuilder,
|
||||
fundsAmount,
|
||||
}: {
|
||||
messageBuilder: Function
|
||||
fundsAmount?: string
|
||||
}) => {
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
|
||||
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS must be defined',
|
||||
)
|
||||
|
||||
const sender = wallet.getAccount().address
|
||||
// Prepare execution message to send
|
||||
const executeTransferContractMsgs = [
|
||||
{
|
||||
typeUrl,
|
||||
value: MsgExecuteContract.fromPartial({
|
||||
sender,
|
||||
contract: process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
|
||||
msg: toUtf8(JSON.stringify(messageBuilder())),
|
||||
...(fundsAmount && {
|
||||
funds: [{ denom: 'untrn', amount: fundsAmount }],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
// Send message
|
||||
return signingCosmClient.signAndBroadcast(
|
||||
sender,
|
||||
executeTransferContractMsgs,
|
||||
{
|
||||
amount: coins(1, 'untrn'),
|
||||
gas: '400000',
|
||||
},
|
||||
)
|
||||
}
|
||||
// Transfer contract query message
|
||||
const queryTransferContract = ({
|
||||
messageBuilder,
|
||||
}: {
|
||||
messageBuilder: Function
|
||||
}) => {
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
|
||||
'NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS must be defined',
|
||||
)
|
||||
|
||||
// Send message
|
||||
return signingCosmClient.queryContractSmart(
|
||||
process.env.NEXT_PUBLIC_TRANSFERS_CONTRACT_ADDRESS,
|
||||
messageBuilder(),
|
||||
)
|
||||
}
|
||||
|
||||
// Define the Cosm wrapper interface to be used
|
||||
export const cosm = {
|
||||
executeTransferContract,
|
||||
init,
|
||||
queryTransferContract,
|
||||
}
|
52
apps/transfers/frontend/src/lib/ephemeralKeypair.ts
Normal file
52
apps/transfers/frontend/src/lib/ephemeralKeypair.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import {
|
||||
Bip39,
|
||||
EnglishMnemonic,
|
||||
Random,
|
||||
Secp256k1,
|
||||
Secp256k1Keypair,
|
||||
} from '@cosmjs/crypto'
|
||||
import { decrypt as _decrypt } from 'eciesjs'
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
// Generate a random Mnemonic
|
||||
export function generateMnemonic() {
|
||||
return Bip39.encode(Random.getBytes(32)).toString()
|
||||
}
|
||||
// Retrieve the Mnemonic from storage
|
||||
export function getMnemonic() {
|
||||
return localStorage.getItem('ephemeral-mnemonic')!
|
||||
}
|
||||
// Save Mnemonic into storage
|
||||
export function saveMnemonic(mnemonic: string) {
|
||||
localStorage.setItem('ephemeral-mnemonic', mnemonic)
|
||||
}
|
||||
// Clear stored mnemonic
|
||||
export function clearMnemonic() {
|
||||
localStorage.removeItem('ephemeral-mnemonic')
|
||||
}
|
||||
// Generate an ephemeral key pair to encryp/decrypt messages for the user from stored mnemonic
|
||||
export async function getEphemeralKeypair(): Promise<Secp256k1Keypair> {
|
||||
let privkeyFromMnemonic = Bip39.decode(new EnglishMnemonic(getMnemonic()))
|
||||
|
||||
// If mnemonic is not formed by 24 words, lets expand it
|
||||
if (privkeyFromMnemonic.length < 32) {
|
||||
const newPrivKey = new Uint8Array(32)
|
||||
|
||||
newPrivKey.set(privkeyFromMnemonic)
|
||||
|
||||
privkeyFromMnemonic = newPrivKey
|
||||
}
|
||||
|
||||
return Secp256k1.makeKeypair(privkeyFromMnemonic)
|
||||
}
|
||||
// Decrypt data using the ephemeral private key
|
||||
export async function decrypt(data?: string): Promise<string> {
|
||||
if (isEmpty(data)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return _decrypt(
|
||||
(await getEphemeralKeypair()).privkey,
|
||||
Buffer.from(data!, 'hex'),
|
||||
).toString()
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import {
|
||||
Bip39,
|
||||
EnglishMnemonic,
|
||||
Random,
|
||||
Secp256k1,
|
||||
Secp256k1Keypair,
|
||||
} from '@cosmjs/crypto'
|
||||
|
||||
const storageKey = 'ephemeral-mnemonic'
|
||||
|
||||
// Retrieve from localstorage a mnemonic or create it if none exists to be used
|
||||
// as input to generate an ephemeral key pair to encryp/decrypt messages for the user
|
||||
export async function getEphemeralKeypair(): Promise<Secp256k1Keypair> {
|
||||
const storedMnemonic = localStorage.getItem(storageKey)
|
||||
const mnemonic =
|
||||
storedMnemonic ?? Bip39.encode(Random.getBytes(32)).toString()
|
||||
|
||||
if (!storedMnemonic) {
|
||||
localStorage.setItem(storageKey, mnemonic)
|
||||
}
|
||||
|
||||
return Secp256k1.makeKeypair(Bip39.decode(new EnglishMnemonic(mnemonic)))
|
||||
}
|
11
apps/transfers/frontend/src/lib/isValidAddress.ts
Normal file
11
apps/transfers/frontend/src/lib/isValidAddress.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { fromBech32 } from '@cosmjs/encoding'
|
||||
|
||||
export const isValidAddress = (address: string): boolean => {
|
||||
try {
|
||||
fromBech32(address)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
40
apps/transfers/frontend/src/lib/notifications.tsx
Normal file
40
apps/transfers/frontend/src/lib/notifications.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { closeSnackbar, enqueueSnackbar, VariantType } from 'notistack'
|
||||
|
||||
import { Icon } from '@/components/Icon'
|
||||
import { StyledText } from '@/components/StyledText'
|
||||
|
||||
type Notification = {
|
||||
message: string
|
||||
dismissible?: boolean
|
||||
variant?: VariantType
|
||||
}
|
||||
|
||||
export const showNotification = ({
|
||||
message,
|
||||
dismissible = false,
|
||||
variant = 'info',
|
||||
}: Notification) =>
|
||||
enqueueSnackbar(message, {
|
||||
variant,
|
||||
...(dismissible && {
|
||||
persist: true,
|
||||
action: (snackbarId) => (
|
||||
<StyledText
|
||||
as="button"
|
||||
variant="button.tool"
|
||||
onClick={() => closeSnackbar(snackbarId)}
|
||||
>
|
||||
<Icon
|
||||
className="text-white"
|
||||
name="xmark"
|
||||
/>
|
||||
</StyledText>
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
export const showSuccess = (message: string) =>
|
||||
showNotification({ message, variant: 'success' })
|
||||
|
||||
export const showError = (message: string) =>
|
||||
showNotification({ message, dismissible: true, variant: 'error' })
|
|
@ -1,10 +0,0 @@
|
|||
export type FormActionResponse =
|
||||
| null
|
||||
| {
|
||||
success: true
|
||||
messages?: string[]
|
||||
}
|
||||
| {
|
||||
success: false
|
||||
messages: string[]
|
||||
}
|
|
@ -1,68 +0,0 @@
|
|||
import { AccountData, Keplr, Window } from '@keplr-wallet/types'
|
||||
import { OfflineSigner } from '@cosmjs/proto-signing'
|
||||
import { Secp256k1Keypair } from '@cosmjs/crypto'
|
||||
import { decrypt as _decrypt } from 'eciesjs'
|
||||
import { isEmpty } from 'lodash'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import { chain } from './chainConfig'
|
||||
import { getEphemeralKeypair } from './getEphemeralKeypair'
|
||||
|
||||
// Wallet variables declaration. They will be set upon initialization
|
||||
let keplr: Keplr | undefined
|
||||
let signer: OfflineSigner
|
||||
let account: AccountData
|
||||
let keypair: Secp256k1Keypair
|
||||
|
||||
// Setup the Keprl Wallet wrapper
|
||||
const init = async () => {
|
||||
keplr = (window as Window).keplr
|
||||
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_CHAIN_ID,
|
||||
'NEXT_PUBLIC_CHAIN_ID must be defined',
|
||||
)
|
||||
invariant(keplr, 'No Keplr wallet found')
|
||||
|
||||
// Generate user ephemeral key pair for decryption
|
||||
keypair = await getEphemeralKeypair()
|
||||
// Set chain info in Keplr
|
||||
await keplr.experimentalSuggestChain(chain)
|
||||
// Init chain in Keplr
|
||||
await keplr.enable(process.env.NEXT_PUBLIC_CHAIN_ID)
|
||||
await refreshUser()
|
||||
}
|
||||
// Decrypt data using the ephemeral private key
|
||||
const decrypt = (data: string): string => {
|
||||
let result = ''
|
||||
|
||||
if (!isEmpty(data)) {
|
||||
result = _decrypt(
|
||||
wallet.getKeypair().privkey,
|
||||
Buffer.from(data, 'hex'),
|
||||
).toString()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
// Get the current Keprl Wallet account
|
||||
const getAccount = (): AccountData => account
|
||||
// Get the autogenerated ephemeral keypair
|
||||
const getKeypair = (): Secp256k1Keypair => keypair
|
||||
// Get the current Keprl Wallet signer
|
||||
const getSigner = (): OfflineSigner => signer
|
||||
// Update new Keprl Wallet user info
|
||||
const refreshUser = async (): Promise<void> => {
|
||||
signer = keplr!.getOfflineSigner(process.env.NEXT_PUBLIC_CHAIN_ID!)
|
||||
account = (await signer.getAccounts())[0]
|
||||
}
|
||||
|
||||
// Define the Keprl Wallet wrapper interface to be used
|
||||
export const wallet = {
|
||||
decrypt,
|
||||
init,
|
||||
getAccount,
|
||||
getKeypair,
|
||||
getSigner,
|
||||
refreshUser,
|
||||
}
|
|
@ -1,20 +1,15 @@
|
|||
import { WebsocketClient } from '@cosmjs/tendermint-rpc'
|
||||
import invariant from 'tiny-invariant'
|
||||
import { Listener } from 'xstream'
|
||||
|
||||
import chain from '@/config/chain'
|
||||
|
||||
// Connect and listen to blockchain events
|
||||
export const wasmEventHandler = (
|
||||
query: string,
|
||||
listener: Partial<Listener<any>>,
|
||||
): (() => void) => {
|
||||
invariant(
|
||||
process.env.NEXT_PUBLIC_CHAIN_RPC_URL,
|
||||
'NEXT_PUBLIC_CHAIN_RPC_URL must be defined',
|
||||
)
|
||||
// Create websocket connection to terdermint
|
||||
const websocketClient = new WebsocketClient(
|
||||
process.env.NEXT_PUBLIC_CHAIN_RPC_URL.replace('http', 'ws'),
|
||||
)
|
||||
const websocketClient = new WebsocketClient(chain.rpc.replace('http', 'ws'))
|
||||
|
||||
// Listen to target query
|
||||
websocketClient
|
||||
|
|
11
apps/transfers/frontend/src/state/useGlobalState.ts
Normal file
11
apps/transfers/frontend/src/state/useGlobalState.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { create } from 'zustand'
|
||||
|
||||
interface GlobalState {
|
||||
loading: boolean
|
||||
setLoading: (loading: boolean) => void
|
||||
}
|
||||
|
||||
export const useGlobalState = create<GlobalState>((set) => ({
|
||||
loading: true,
|
||||
setLoading: (loading: boolean) => set({ loading }),
|
||||
}))
|
|
@ -4,7 +4,7 @@ import type { Config } from 'tailwindcss'
|
|||
import colors from 'tailwindcss/colors'
|
||||
import plugin from 'tailwindcss/plugin'
|
||||
|
||||
const accentColorBucket = colors.violet
|
||||
const accentColorBucket = colors.blue
|
||||
const neutralColorBucket = colors.stone
|
||||
const borderColor = neutralColorBucket[200]
|
||||
|
||||
|
@ -21,11 +21,6 @@ const config: Config = {
|
|||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic':
|
||||
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
borderColor: {
|
||||
DEFAULT: borderColor,
|
||||
},
|
||||
|
|
|
@ -22,6 +22,12 @@
|
|||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"env.d.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue