Add transfer frontend (#93)
This commit is contained in:
parent
065747ec2a
commit
8feb20b7b8
51 changed files with 11921 additions and 0 deletions
5
apps/transfers/frontend/.env.example
Normal file
5
apps/transfers/frontend/.env.example
Normal file
|
@ -0,0 +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_TRANSFERS_CONTRACT_ADDRESS=wasm1jfgr0vgunezkhfmdy7krrupu6yjhx224nxtjptll2ylkkqhyzeshrspu9
|
7
apps/transfers/frontend/.eslintrc.json
Normal file
7
apps/transfers/frontend/.eslintrc.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals",
|
||||||
|
"plugins": ["react-compiler"],
|
||||||
|
"rules": {
|
||||||
|
"react-compiler/react-compiler": "error"
|
||||||
|
}
|
||||||
|
}
|
36
apps/transfers/frontend/.gitignore
vendored
Normal file
36
apps/transfers/frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
11
apps/transfers/frontend/.prettierrc
Normal file
11
apps/transfers/frontend/.prettierrc
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
|
"quoteProps": "consistent",
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": false,
|
||||||
|
"singleAttributePerLine": true,
|
||||||
|
"tailwindFunctions": ["tw", "twJoin", "twMerge"],
|
||||||
|
"tailwindPreserveWhitespace": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
43
apps/transfers/frontend/README.md
Normal file
43
apps/transfers/frontend/README.md
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
# Transfer App
|
||||||
|
|
||||||
|
This is an example frontend that illustrates how to interact with a Transfer Quartz App.
|
||||||
|
|
||||||
|
This example offers:
|
||||||
|
|
||||||
|
- Deposit amounts into a balance
|
||||||
|
- Withdraw the whole deposit
|
||||||
|
- Transfer amounts between wallet addresses in a private-preserving way
|
||||||
|
- Query your encrypted balance to capture changes
|
||||||
|
- Switch between Keplr wallets
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
In order to get started, you will need:
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/) LTS (v20.x)
|
||||||
|
- `npm`
|
||||||
|
- A [Keplr](https://www.keplr.app/) Wallet
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
The App requires some environment variables to fully work. Be sure to set up those accordingly to your local environment.
|
||||||
|
|
||||||
|
You should start from the template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
And now everything is up & running 🎉
|
8
apps/transfers/frontend/next.config.mjs
Normal file
8
apps/transfers/frontend/next.config.mjs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
experimental: {
|
||||||
|
reactCompiler: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
5709
apps/transfers/frontend/package-lock.json
generated
Normal file
5709
apps/transfers/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
47
apps/transfers/frontend/package.json
Normal file
47
apps/transfers/frontend/package.json
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"name": "transfer-fe",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"generate": "node ./scripts/generate.js && node ./scripts/rebuild-component-index.js",
|
||||||
|
"rebuild-component-index": "node ./scripts/rebuild-component-index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cosmjs/cosmwasm-stargate": "^0.32.4",
|
||||||
|
"@cosmwasm/ts-codegen": "^1.11.1",
|
||||||
|
"eciesjs": "^0.4.7",
|
||||||
|
"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",
|
||||||
|
"tailwind-merge": "^2.3.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@keplr-wallet/types": "0.12.103",
|
||||||
|
"@netlify/plugin-nextjs": "^5.3.3",
|
||||||
|
"@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",
|
||||||
|
"babel-plugin-react-compiler": "0.0.0-experimental-696af53-20240625",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-plugin-react-compiler": "^0.0.0",
|
||||||
|
"postcss": "^8",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@types/react": "npm:types-react@rc",
|
||||||
|
"@types/react-dom": "npm:types-react-dom@rc"
|
||||||
|
}
|
||||||
|
}
|
8
apps/transfers/frontend/postcss.config.mjs
Normal file
8
apps/transfers/frontend/postcss.config.mjs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
BIN
apps/transfers/frontend/public/favicon.png
Normal file
BIN
apps/transfers/frontend/public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/transfers/frontend/public/images/moroccan-flower.png
Normal file
BIN
apps/transfers/frontend/public/images/moroccan-flower.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
105
apps/transfers/frontend/scripts/generate.js
Normal file
105
apps/transfers/frontend/scripts/generate.js
Normal file
|
@ -0,0 +1,105 @@
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
43
apps/transfers/frontend/scripts/rebuild-component-index.js
Normal file
43
apps/transfers/frontend/scripts/rebuild-component-index.js
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
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}`)
|
24
apps/transfers/frontend/scripts/templates/NewComponent.tsx
Normal file
24
apps/transfers/frontend/scripts/templates/NewComponent.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { tw } from '@/lib/tw'
|
||||||
|
|
||||||
|
export const classNames = {
|
||||||
|
container: tw`
|
||||||
|
// styles here
|
||||||
|
`,
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { NewComponent } from './NewComponent'
|
3
apps/transfers/frontend/src/app/globals.css
Normal file
3
apps/transfers/frontend/src/app/globals.css
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
66
apps/transfers/frontend/src/app/layout.tsx
Normal file
66
apps/transfers/frontend/src/app/layout.tsx
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
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'
|
||||||
|
|
||||||
|
const bodyFont = Raleway({ subsets: ['latin'], variable: '--font-raleway' })
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Cycles: Respect the Graph',
|
||||||
|
description: 'The Open Clearing Protocol.',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/png',
|
||||||
|
sizes: '32x32',
|
||||||
|
url: '/favicon.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
openGraph: {
|
||||||
|
type: 'website',
|
||||||
|
url: 'https://example.com',
|
||||||
|
title: 'Cycles: Respect the Graph',
|
||||||
|
description: 'The Open Clearing Protocol.',
|
||||||
|
siteName: 'Cycles',
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: 'http://cycles.money/share-sheet-image.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: 'summary_large_image',
|
||||||
|
site: '@site',
|
||||||
|
creator: '@creator',
|
||||||
|
images: 'http://cycles.money/share-sheet-image.jpg',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<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>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
229
apps/transfers/frontend/src/app/page.tsx
Normal file
229
apps/transfers/frontend/src/app/page.tsx
Normal file
|
@ -0,0 +1,229 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { isEmpty } from 'lodash'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DepositModalWindow,
|
||||||
|
Icon,
|
||||||
|
Notifications,
|
||||||
|
StyledText,
|
||||||
|
TransferModalWindow,
|
||||||
|
WithdrawModalWindow,
|
||||||
|
} from '@/components'
|
||||||
|
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'
|
||||||
|
|
||||||
|
function formatAmount(value: number) {
|
||||||
|
return value.toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: chain.stakeCurrency?.coinDecimals,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe method to get the balance amount from the decrypted data
|
||||||
|
const retrieveBalance = (data: string) => {
|
||||||
|
let balance = 0
|
||||||
|
|
||||||
|
if (!isEmpty(data)) {
|
||||||
|
const json = JSON.parse(data)
|
||||||
|
|
||||||
|
balance = Number(json.balance ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return balance
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [requestBalanceResult, setRequestBalanceResult] =
|
||||||
|
useState<FormActionResponse>()
|
||||||
|
const [balance, setBalance] = useState(0)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [walletAddress, setWalletAddress] = useState(
|
||||||
|
wallet.getAccount().address,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
window.addEventListener(...params)
|
||||||
|
|
||||||
|
return () => window.removeEventListener(...params)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
console.log(event)
|
||||||
|
if (!isEmpty(event?.events['wasm-store_balance.encrypted_balance'])) {
|
||||||
|
setBalance(
|
||||||
|
retrieveBalance(
|
||||||
|
wallet.decrypt(
|
||||||
|
event.events['wasm-store_balance.encrypted_balance'][0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, [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),
|
||||||
|
})
|
||||||
|
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 (
|
||||||
|
<main
|
||||||
|
className={tw`
|
||||||
|
flex
|
||||||
|
h-screen
|
||||||
|
flex-col
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
bg-[url(/images/moroccan-flower.png)]
|
||||||
|
p-12
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Notifications formActionResponse={requestBalanceResult} />
|
||||||
|
<div
|
||||||
|
className={tw`
|
||||||
|
flex
|
||||||
|
flex-col
|
||||||
|
gap-2
|
||||||
|
divide-y
|
||||||
|
rounded-md
|
||||||
|
border
|
||||||
|
border-black/20
|
||||||
|
bg-white
|
||||||
|
p-5
|
||||||
|
py-3
|
||||||
|
shadow-2xl
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex w-full justify-between">
|
||||||
|
<span className="font-bold">Balance:</span>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
Get Balance
|
||||||
|
</StyledText>
|
||||||
|
|
||||||
|
<div className="my-1 w-full border-black/20"></div>
|
||||||
|
|
||||||
|
<StyledText
|
||||||
|
className="w-full bg-emerald-500 font-bold"
|
||||||
|
variant="button.primary"
|
||||||
|
onClick={() => setIsDepositModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon name="piggy-bank" />
|
||||||
|
Deposit
|
||||||
|
</StyledText>
|
||||||
|
<StyledText
|
||||||
|
variant="button.primary"
|
||||||
|
className="w-full font-bold"
|
||||||
|
onClick={() => setIsTransferModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon name="arrows-left-right" />
|
||||||
|
Transfer
|
||||||
|
</StyledText>
|
||||||
|
<StyledText
|
||||||
|
className="w-full bg-amber-500 font-bold"
|
||||||
|
variant="button.primary"
|
||||||
|
onClick={() => setIsWithdrawModalOpen(true)}
|
||||||
|
>
|
||||||
|
<Icon name="money-bills-simple" />
|
||||||
|
Withdraw
|
||||||
|
</StyledText>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DepositModalWindow
|
||||||
|
isOpen={isDepositModalOpen}
|
||||||
|
onClose={() => setIsDepositModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<TransferModalWindow
|
||||||
|
isOpen={isTransferModalOpen}
|
||||||
|
onClose={() => setIsTransferModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<WithdrawModalWindow
|
||||||
|
isOpen={isWithdrawModalOpen}
|
||||||
|
onClose={() => setIsWithdrawModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
27
apps/transfers/frontend/src/components/App.tsx
Normal file
27
apps/transfers/frontend/src/components/App.tsx
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
'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}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
107
apps/transfers/frontend/src/components/DepositModalWindow.tsx
Normal file
107
apps/transfers/frontend/src/components/DepositModalWindow.tsx
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ChangeEvent, useActionState, useState } from 'react'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(result)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: ['woo!'],
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
messages: ['Something went wrong'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DepositModalWindow({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
...otherProps
|
||||||
|
}: ModalWindowProps) {
|
||||||
|
const [amount, setAmount] = useState(0)
|
||||||
|
const [formActionResponse, formAction, isLoading] = useActionState(
|
||||||
|
handleDeposit,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalWindow
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<LoadingSpinner isLoading={isLoading} />
|
||||||
|
|
||||||
|
<ModalWindow.Title className="bg-emerald-500">Deposit</ModalWindow.Title>
|
||||||
|
|
||||||
|
<form action={formAction}>
|
||||||
|
<ModalWindow.Body className="space-y-3">
|
||||||
|
<Notifications formActionResponse={formActionResponse} />
|
||||||
|
|
||||||
|
<StyledBox
|
||||||
|
as="input"
|
||||||
|
className={tw`
|
||||||
|
focus:!border-emerald-500
|
||||||
|
focus:!outline-emerald-500
|
||||||
|
focus:!ring-emerald-500
|
||||||
|
`}
|
||||||
|
min={0}
|
||||||
|
name="amount"
|
||||||
|
placeholder="0.00"
|
||||||
|
type="number"
|
||||||
|
value={amount || ''}
|
||||||
|
variant="input"
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setAmount(Number(event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ModalWindow.Body>
|
||||||
|
|
||||||
|
<ModalWindow.Buttons>
|
||||||
|
<StyledText
|
||||||
|
as="button"
|
||||||
|
className="bg-emerald-500"
|
||||||
|
disabled={amount === 0}
|
||||||
|
variant="button.primary"
|
||||||
|
>
|
||||||
|
Deposit
|
||||||
|
</StyledText>
|
||||||
|
<StyledText
|
||||||
|
variant="button.secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledText>
|
||||||
|
</ModalWindow.Buttons>
|
||||||
|
</form>
|
||||||
|
</ModalWindow>
|
||||||
|
)
|
||||||
|
}
|
39
apps/transfers/frontend/src/components/Icon/Icon.tsx
Normal file
39
apps/transfers/frontend/src/components/Icon/Icon.tsx
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { IconProps } from './types'
|
||||||
|
|
||||||
|
const Icon = ({
|
||||||
|
className,
|
||||||
|
name,
|
||||||
|
rotate,
|
||||||
|
spin = false,
|
||||||
|
variant = 'solid',
|
||||||
|
...otherProps
|
||||||
|
}: IconProps) => (
|
||||||
|
<span
|
||||||
|
className={twMerge(`!no-underline`, className)}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
className={twMerge(
|
||||||
|
`
|
||||||
|
fa
|
||||||
|
fa-fw
|
||||||
|
fa-${name}
|
||||||
|
`,
|
||||||
|
typeof rotate === 'string' && `fa-${rotate}`,
|
||||||
|
typeof rotate === 'number' && `fa-rotate-${rotate}`,
|
||||||
|
spin && `fa-spin`,
|
||||||
|
variant.startsWith('sharp-')
|
||||||
|
? `
|
||||||
|
fa-sharp
|
||||||
|
fa-${variant.replace('sharp-', '')}
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
fa-${variant}
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
export { Icon }
|
9
apps/transfers/frontend/src/components/Icon/index.ts
Normal file
9
apps/transfers/frontend/src/components/Icon/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export { Icon } from "./Icon"
|
||||||
|
export type {
|
||||||
|
BrandIconName,
|
||||||
|
IconName,
|
||||||
|
IconProps,
|
||||||
|
IconRotationOption,
|
||||||
|
IconVariant,
|
||||||
|
RegularIconName,
|
||||||
|
} from "./types"
|
3799
apps/transfers/frontend/src/components/Icon/types.ts
Normal file
3799
apps/transfers/frontend/src/components/Icon/types.ts
Normal file
File diff suppressed because it is too large
Load diff
47
apps/transfers/frontend/src/components/LoadingSpinner.tsx
Normal file
47
apps/transfers/frontend/src/components/LoadingSpinner.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { Icon } from '@/components/Icon'
|
||||||
|
import { ComponentProps } from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps extends Omit<ComponentProps<'div'>, 'children'> {
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({
|
||||||
|
className,
|
||||||
|
isLoading,
|
||||||
|
...otherProps
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={twMerge(
|
||||||
|
`
|
||||||
|
pointer-events-none
|
||||||
|
absolute
|
||||||
|
bottom-0
|
||||||
|
left-0
|
||||||
|
right-0
|
||||||
|
top-0
|
||||||
|
z-20
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
bg-appBgColor
|
||||||
|
transition-all
|
||||||
|
`,
|
||||||
|
isLoading
|
||||||
|
? `
|
||||||
|
opacity-100
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
opacity-0
|
||||||
|
`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<div className="animate-spin">
|
||||||
|
<Icon name="spinner" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { ComponentProps, useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { classNames } from './classNames'
|
||||||
|
|
||||||
|
export interface ModalWindowProps extends ComponentProps<'div'> {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalWindow({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
...otherProps
|
||||||
|
}: ModalWindowProps) {
|
||||||
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
|
||||||
|
const [modalState, setModalState] = useState<
|
||||||
|
'opening' | 'open' | 'closing' | 'closed'
|
||||||
|
>('closed')
|
||||||
|
|
||||||
|
const windowContentsContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
function handleTransitionEnd() {
|
||||||
|
if (modalState === 'closing') {
|
||||||
|
setModalState('closed')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
if (modalState === 'opening') {
|
||||||
|
setModalState('open')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setModalState('closing')
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusFirstElement() {
|
||||||
|
const windowContentsContainer = windowContentsContainerRef.current
|
||||||
|
|
||||||
|
if (!windowContentsContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstFocusableElement = windowContentsContainer.querySelector(
|
||||||
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||||
|
) as HTMLElement
|
||||||
|
|
||||||
|
if (firstFocusableElement) {
|
||||||
|
firstFocusableElement.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setModalState('opening')
|
||||||
|
focusFirstElement()
|
||||||
|
} else {
|
||||||
|
setModalState('closing')
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
function handleEscape(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleEscape)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose])
|
||||||
|
|
||||||
|
if (!isClient) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={classNames.backdrop({ modalState })}
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={twMerge(classNames.container({ modalState }), className)}
|
||||||
|
ref={windowContentsContainerRef}
|
||||||
|
onTransitionEnd={handleTransitionEnd}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalWindow.Title = function ModalWindowTitle({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ComponentProps<'header'>) {
|
||||||
|
return <div className={twMerge(classNames.header, className)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalWindow.Body = function ModalWindowBody({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ComponentProps<'main'>) {
|
||||||
|
return <div className={twMerge(classNames.body, className)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
ModalWindow.Buttons = function ModalWindowBody({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div className={twMerge(classNames.buttons, className)}>{children}</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { tw } from "@/lib/tw";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export const classNames = {
|
||||||
|
backdrop: ({ modalState = 'closed' }) => twMerge(
|
||||||
|
`
|
||||||
|
fixed
|
||||||
|
left-0
|
||||||
|
top-0
|
||||||
|
h-screen
|
||||||
|
w-full
|
||||||
|
transition-all
|
||||||
|
z-10
|
||||||
|
duration-500
|
||||||
|
`,
|
||||||
|
(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(
|
||||||
|
`
|
||||||
|
fixed
|
||||||
|
left-1/2
|
||||||
|
top-1/2
|
||||||
|
bg-appBgColor
|
||||||
|
rounded-lg
|
||||||
|
overflow-hidden
|
||||||
|
-translate-x-1/2
|
||||||
|
z-10
|
||||||
|
duration-500
|
||||||
|
min-w-64
|
||||||
|
shadow-xl
|
||||||
|
`,
|
||||||
|
(modalState === 'opening' || modalState === 'open')
|
||||||
|
?
|
||||||
|
`
|
||||||
|
opacity-100
|
||||||
|
-translate-y-1/2
|
||||||
|
`
|
||||||
|
:
|
||||||
|
`
|
||||||
|
opacity-0
|
||||||
|
pointer-events-none
|
||||||
|
-translate-y-full
|
||||||
|
`
|
||||||
|
),
|
||||||
|
|
||||||
|
header: tw`
|
||||||
|
px-3
|
||||||
|
py-1
|
||||||
|
bg-accentColor
|
||||||
|
text-white
|
||||||
|
font-bold
|
||||||
|
`,
|
||||||
|
|
||||||
|
body: tw`
|
||||||
|
p-3
|
||||||
|
`,
|
||||||
|
|
||||||
|
buttons: tw`
|
||||||
|
flex
|
||||||
|
flex-row-reverse
|
||||||
|
gap-1
|
||||||
|
p-3
|
||||||
|
`
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { ModalWindow, type ModalWindowProps } from './ModalWindow'
|
|
@ -0,0 +1,140 @@
|
||||||
|
'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>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,90 @@
|
||||||
|
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
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { Notifications } from './Notifications'
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { ComponentProps, ElementType } from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { classNames } from './classNames'
|
||||||
|
|
||||||
|
type StyledBoxVariant = keyof typeof classNames
|
||||||
|
|
||||||
|
type StyledBoxProps<T extends ElementType = 'div'> = Omit<
|
||||||
|
ComponentProps<T>,
|
||||||
|
'variant'
|
||||||
|
> & {
|
||||||
|
as?: T
|
||||||
|
variant?: StyledBoxVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StyledBox<T extends ElementType = 'div'>({
|
||||||
|
as,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...otherProps
|
||||||
|
}: StyledBoxProps<T>) {
|
||||||
|
const Component = as || 'div'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={twMerge(variant && classNames[variant], className)}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { tw } from '@/lib/tw'
|
||||||
|
|
||||||
|
export const classNames = {
|
||||||
|
container: tw`
|
||||||
|
container
|
||||||
|
mx-auto
|
||||||
|
max-sm:px-6
|
||||||
|
md:max-w-screen-sm
|
||||||
|
lg:max-w-screen-md
|
||||||
|
xl:max-w-screen-lg
|
||||||
|
2xl:max-w-screen-xl
|
||||||
|
`,
|
||||||
|
|
||||||
|
checkbox: tw`
|
||||||
|
text-bgColor
|
||||||
|
size-7
|
||||||
|
rounded-md
|
||||||
|
border-2
|
||||||
|
border-borderColor
|
||||||
|
!outline-accentColor
|
||||||
|
!ring-accentColor
|
||||||
|
checked:text-accentColor
|
||||||
|
`,
|
||||||
|
|
||||||
|
input: tw`
|
||||||
|
w-full
|
||||||
|
rounded-md
|
||||||
|
border
|
||||||
|
border-borderColor
|
||||||
|
bg-transparent
|
||||||
|
px-3
|
||||||
|
py-2
|
||||||
|
!outline-accentColor
|
||||||
|
!ring-accentColor
|
||||||
|
focus:border-accentColor
|
||||||
|
[appearance:textfield]
|
||||||
|
`,
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { StyledBox } from './StyledBox'
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { ComponentProps, ElementType } from 'react'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { classNames } from './classNames'
|
||||||
|
|
||||||
|
type StyledTextVariant = keyof typeof classNames
|
||||||
|
|
||||||
|
type StyledTextProps<T extends ElementType = 'span'> = Omit<
|
||||||
|
ComponentProps<T>,
|
||||||
|
'variant'
|
||||||
|
> & {
|
||||||
|
as?: T
|
||||||
|
variant?: StyledTextVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StyledText<T extends ElementType = 'span'>({
|
||||||
|
as,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...otherProps
|
||||||
|
}: StyledTextProps<T>) {
|
||||||
|
const Component = as || 'span'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={twMerge(variant && classNames[variant], className)}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
153
apps/transfers/frontend/src/components/StyledText/classNames.tsx
Normal file
153
apps/transfers/frontend/src/components/StyledText/classNames.tsx
Normal file
|
@ -0,0 +1,153 @@
|
||||||
|
import { tw } from '@/lib/tw'
|
||||||
|
import { twJoin, twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
const buttonClassNames = tw`
|
||||||
|
inline-flex
|
||||||
|
w-min
|
||||||
|
cursor-pointer
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
gap-2
|
||||||
|
transition-all
|
||||||
|
hover:scale-105
|
||||||
|
disabled:pointer-events-none
|
||||||
|
disabled:opacity-40
|
||||||
|
`
|
||||||
|
|
||||||
|
const headingClassNames = tw`
|
||||||
|
font-medium
|
||||||
|
[&_span]:font-display
|
||||||
|
[&_span]:font-medium
|
||||||
|
[&_span]:italic
|
||||||
|
[&_span]:text-accentColor
|
||||||
|
`
|
||||||
|
|
||||||
|
export const classNames = {
|
||||||
|
'button.primary': twMerge(
|
||||||
|
buttonClassNames,
|
||||||
|
`
|
||||||
|
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
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
|
||||||
|
'button.icon': twMerge(
|
||||||
|
buttonClassNames,
|
||||||
|
`
|
||||||
|
size-10
|
||||||
|
rounded-full
|
||||||
|
bg-shadedBgColor
|
||||||
|
hover:bg-accentColor
|
||||||
|
hover:text-appBgColor
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
|
||||||
|
'button.tool': tw`
|
||||||
|
inline-flex
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
gap-2
|
||||||
|
rounded-sm
|
||||||
|
px-1
|
||||||
|
py-0.5
|
||||||
|
text-sm
|
||||||
|
text-textColor
|
||||||
|
hover:bg-textColor/15
|
||||||
|
`,
|
||||||
|
|
||||||
|
'footnote': tw`
|
||||||
|
text-sm
|
||||||
|
text-fadedTextColor
|
||||||
|
`,
|
||||||
|
|
||||||
|
'h1': twJoin(
|
||||||
|
headingClassNames,
|
||||||
|
`
|
||||||
|
text-5xl
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
|
||||||
|
'h2': twJoin(
|
||||||
|
headingClassNames,
|
||||||
|
`
|
||||||
|
text-4xl
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
|
||||||
|
'h3': twJoin(
|
||||||
|
headingClassNames,
|
||||||
|
`
|
||||||
|
text-3xl
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
|
||||||
|
'h4': twJoin(
|
||||||
|
headingClassNames,
|
||||||
|
`
|
||||||
|
text-lg
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
|
||||||
|
'label': tw`
|
||||||
|
text-sm
|
||||||
|
text-fadedTextColor
|
||||||
|
`,
|
||||||
|
|
||||||
|
'link': twMerge(
|
||||||
|
buttonClassNames,
|
||||||
|
`
|
||||||
|
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
|
||||||
|
`,
|
||||||
|
),
|
||||||
|
|
||||||
|
'logo': tw`
|
||||||
|
inline-flex
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
gap-[0.35em]
|
||||||
|
font-semibold
|
||||||
|
uppercase
|
||||||
|
tracking-[0.2em]
|
||||||
|
[&_span]:inline-block
|
||||||
|
[&_span]:border-l
|
||||||
|
[&_span]:pl-[0.5em]
|
||||||
|
[&_span]:font-light
|
||||||
|
[&_span]:tracking-[0]
|
||||||
|
`,
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { StyledText } from './StyledText'
|
144
apps/transfers/frontend/src/components/TransferModalWindow.tsx
Normal file
144
apps/transfers/frontend/src/components/TransferModalWindow.tsx
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
'use client'
|
||||||
|
import { PublicKey, encrypt } from 'eciesjs'
|
||||||
|
import { ChangeEvent, useActionState, useState } from 'react'
|
||||||
|
import invariant from 'tiny-invariant'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
// Encrypt the transfer data using the enclave public key
|
||||||
|
function encryptMsg(data: {
|
||||||
|
sender: string
|
||||||
|
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
|
||||||
|
const serializedState = JSON.stringify(data)
|
||||||
|
// Encrypt the data
|
||||||
|
const encryptedState = encrypt(
|
||||||
|
pubkey.toHex(),
|
||||||
|
Buffer.from(serializedState, 'utf-8'),
|
||||||
|
)
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const [amount, setAmount] = useState(0)
|
||||||
|
const [receiver, setRecipient] = useState('')
|
||||||
|
const [formActionResponse, formAction, isLoading] = useActionState(
|
||||||
|
handleTransfer,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalWindow
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<LoadingSpinner isLoading={isLoading} />
|
||||||
|
|
||||||
|
<ModalWindow.Title>Transfer</ModalWindow.Title>
|
||||||
|
|
||||||
|
<form action={formAction}>
|
||||||
|
<ModalWindow.Body className="space-y-3">
|
||||||
|
<Notifications formActionResponse={formActionResponse} />
|
||||||
|
|
||||||
|
<StyledBox
|
||||||
|
as="input"
|
||||||
|
placeholder="recipient address"
|
||||||
|
type="text"
|
||||||
|
variant="input"
|
||||||
|
name="receiver"
|
||||||
|
value={receiver}
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setRecipient(event.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StyledBox
|
||||||
|
as="input"
|
||||||
|
min={0}
|
||||||
|
name="amount"
|
||||||
|
placeholder="0.00"
|
||||||
|
type="number"
|
||||||
|
value={amount || ''}
|
||||||
|
variant="input"
|
||||||
|
onChange={(event: ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setAmount(Number(event.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ModalWindow.Body>
|
||||||
|
|
||||||
|
<ModalWindow.Buttons>
|
||||||
|
<StyledText
|
||||||
|
as="button"
|
||||||
|
disabled={amount === 0}
|
||||||
|
variant="button.primary"
|
||||||
|
>
|
||||||
|
Transfer
|
||||||
|
</StyledText>
|
||||||
|
<StyledText
|
||||||
|
variant="button.secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledText>
|
||||||
|
</ModalWindow.Buttons>
|
||||||
|
</form>
|
||||||
|
</ModalWindow>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
'use client'
|
||||||
|
import { useActionState } from 'react'
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(result)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
messages: ['woo!'],
|
||||||
|
}
|
||||||
|
} 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}
|
||||||
|
>
|
||||||
|
<LoadingSpinner isLoading={isLoading} />
|
||||||
|
|
||||||
|
<ModalWindow.Title className="bg-amber-500">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>
|
||||||
|
</ModalWindow.Body>
|
||||||
|
<ModalWindow.Buttons>
|
||||||
|
<StyledText
|
||||||
|
as="button"
|
||||||
|
className="bg-amber-500"
|
||||||
|
variant="button.primary"
|
||||||
|
>
|
||||||
|
Withdraw
|
||||||
|
</StyledText>
|
||||||
|
<StyledText
|
||||||
|
variant="button.secondary"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</StyledText>
|
||||||
|
</ModalWindow.Buttons>
|
||||||
|
</form>
|
||||||
|
</ModalWindow>
|
||||||
|
)
|
||||||
|
}
|
10
apps/transfers/frontend/src/components/index.ts
Normal file
10
apps/transfers/frontend/src/components/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
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'
|
57
apps/transfers/frontend/src/lib/chainConfig.ts
Normal file
57
apps/transfers/frontend/src/lib/chainConfig.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
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: 'My Testing Chain',
|
||||||
|
stakeCurrency: {
|
||||||
|
coinDenom: 'COSM',
|
||||||
|
coinMinimalDenom: 'ucosm',
|
||||||
|
coinDecimals: 6,
|
||||||
|
coinGeckoId: 'regen',
|
||||||
|
},
|
||||||
|
bip44: {
|
||||||
|
coinType: 118,
|
||||||
|
},
|
||||||
|
bech32Config: {
|
||||||
|
bech32PrefixAccAddr: 'wasm',
|
||||||
|
bech32PrefixAccPub: 'wasm' + 'pub',
|
||||||
|
bech32PrefixValAddr: 'wasm' + 'valoper',
|
||||||
|
bech32PrefixValPub: 'wasm' + 'valoperpub',
|
||||||
|
bech32PrefixConsAddr: 'wasm' + 'valcons',
|
||||||
|
bech32PrefixConsPub: 'wasm' + 'valconspub',
|
||||||
|
},
|
||||||
|
currencies: [
|
||||||
|
{
|
||||||
|
coinDenom: 'COSM',
|
||||||
|
coinMinimalDenom: 'ucosm',
|
||||||
|
coinDecimals: 6,
|
||||||
|
coinGeckoId: 'regen',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
feeCurrencies: [
|
||||||
|
{
|
||||||
|
coinDenom: 'COSM',
|
||||||
|
coinMinimalDenom: 'ucosm',
|
||||||
|
coinDecimals: 6,
|
||||||
|
coinGeckoId: 'regen',
|
||||||
|
gasPriceStep: { low: 0.01, average: 0.025, high: 0.04 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
28
apps/transfers/frontend/src/lib/contractMessageBuilders.ts
Normal file
28
apps/transfers/frontend/src/lib/contractMessageBuilders.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
// Transfer contract operation message formats
|
||||||
|
// This is the whole definition of how the Tranfers contract expects to receive messages
|
||||||
|
export const contractMessageBuilders: {
|
||||||
|
deposit: () => {}
|
||||||
|
getBalance: (address: string) => {
|
||||||
|
get_balance: { address: string }
|
||||||
|
}
|
||||||
|
requestBalance: (pubkey: Uint8Array) => {}
|
||||||
|
transfer: (ciphertext: string) => {
|
||||||
|
transfer_request: {
|
||||||
|
ciphertext: string
|
||||||
|
digest: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
withdraw: () => {}
|
||||||
|
} = {
|
||||||
|
deposit: () => 'deposit',
|
||||||
|
getBalance: (address: string) => ({
|
||||||
|
get_balance: { address },
|
||||||
|
}),
|
||||||
|
requestBalance: (pubkey: Uint8Array) => ({
|
||||||
|
query_request: { emphemeral_pubkey: Buffer.from(pubkey).toString('hex') },
|
||||||
|
}),
|
||||||
|
transfer: (ciphertext: string) => ({
|
||||||
|
transfer_request: { ciphertext, digest: '' },
|
||||||
|
}),
|
||||||
|
withdraw: () => 'withdraw',
|
||||||
|
}
|
91
apps/transfers/frontend/src/lib/cosm.ts
Normal file
91
apps/transfers/frontend/src/lib/cosm.ts
Normal file
|
@ -0,0 +1,91 @@
|
||||||
|
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: 'ucosm', amount: fundsAmount }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// Send message
|
||||||
|
return signingCosmClient.signAndBroadcast(
|
||||||
|
sender,
|
||||||
|
executeTransferContractMsgs,
|
||||||
|
{
|
||||||
|
amount: coins(1, 'ucosm'),
|
||||||
|
gas: '200000',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 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,
|
||||||
|
}
|
23
apps/transfers/frontend/src/lib/getEphemeralKeypair.ts
Normal file
23
apps/transfers/frontend/src/lib/getEphemeralKeypair.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
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)))
|
||||||
|
}
|
3
apps/transfers/frontend/src/lib/tw.ts
Normal file
3
apps/transfers/frontend/src/lib/tw.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
export function tw(strings: TemplateStringsArray, ...values: any[]) {
|
||||||
|
return String.raw({ raw: strings }, ...values)
|
||||||
|
}
|
10
apps/transfers/frontend/src/lib/types.ts
Normal file
10
apps/transfers/frontend/src/lib/types.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
export type FormActionResponse =
|
||||||
|
| null
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
messages?: string[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: false
|
||||||
|
messages: string[]
|
||||||
|
}
|
68
apps/transfers/frontend/src/lib/wallet.ts
Normal file
68
apps/transfers/frontend/src/lib/wallet.ts
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
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,
|
||||||
|
}
|
34
apps/transfers/frontend/src/lib/wasmEventHandler.ts
Normal file
34
apps/transfers/frontend/src/lib/wasmEventHandler.ts
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import { WebsocketClient } from '@cosmjs/tendermint-rpc'
|
||||||
|
import invariant from 'tiny-invariant'
|
||||||
|
import { Listener } from 'xstream'
|
||||||
|
|
||||||
|
// 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'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Listen to target query
|
||||||
|
websocketClient
|
||||||
|
.listen({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'subscribe',
|
||||||
|
params: { query },
|
||||||
|
// Just use some random UUID, we do not need to know which
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
})
|
||||||
|
.subscribe(listener)
|
||||||
|
|
||||||
|
// Callback method to call in case of cleaning
|
||||||
|
return () => {
|
||||||
|
websocketClient.disconnect()
|
||||||
|
}
|
||||||
|
}
|
254
apps/transfers/frontend/tailwind.config.ts
Normal file
254
apps/transfers/frontend/tailwind.config.ts
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
import formsPlugin from '@tailwindcss/forms'
|
||||||
|
import typographyPlugin from '@tailwindcss/typography'
|
||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
import colors from 'tailwindcss/colors'
|
||||||
|
import plugin from 'tailwindcss/plugin'
|
||||||
|
|
||||||
|
const accentColorBucket = colors.violet
|
||||||
|
const neutralColorBucket = colors.stone
|
||||||
|
const borderColor = neutralColorBucket[200]
|
||||||
|
|
||||||
|
const innerRingStartOpacity = 0.8
|
||||||
|
const innerRingEndOpacity = 0.5
|
||||||
|
const outerRingStartOpacity = 0.5
|
||||||
|
const outerRingEndOpacity = 0.1
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
outlineColor: {
|
||||||
|
DEFAULT: borderColor,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
accentColor: accentColorBucket[500],
|
||||||
|
appBgColor: neutralColorBucket[50],
|
||||||
|
borderColor,
|
||||||
|
neutral: neutralColorBucket,
|
||||||
|
textColor: neutralColorBucket[800],
|
||||||
|
fadedTextColor: neutralColorBucket[500],
|
||||||
|
shadedBgColor: accentColorBucket[100],
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
body: ['var(--font-raleway)', 'sans-serif'],
|
||||||
|
display: ['var(--font-bitter)', 'sans-serif'],
|
||||||
|
icon: ["'Font Awesome 6 Pro'"],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
animateInX: 'animateInX 0.5s ease-in-out both',
|
||||||
|
animateInY: 'animateInY 0.5s ease-in-out both',
|
||||||
|
animateOutX: 'animateOutX 0.5s ease-in-out both',
|
||||||
|
animateOutY: 'animateOutY 0.5s ease-in-out both',
|
||||||
|
float: 'float 2.5s ease-in-out infinite alternate',
|
||||||
|
floatUp: 'floatUp 120s linear infinite',
|
||||||
|
forceFieldSmallInner:
|
||||||
|
'forceFieldSmallInner 2s ease-in-out infinite alternate',
|
||||||
|
forceFieldSmallOuter:
|
||||||
|
'forceFieldSmallOuter 2s ease-in-out infinite alternate',
|
||||||
|
forceFieldInner: 'forceFieldInner 2s ease-in-out infinite alternate',
|
||||||
|
forceFieldOuter: 'forceFieldOuter 2s ease-in-out infinite alternate',
|
||||||
|
rotatingSphere: 'rotatingSphere 120s linear infinite',
|
||||||
|
slowDriftLeft: 'driftLeft 60s linear infinite',
|
||||||
|
slowerDriftLeft: 'driftLeft 120s linear infinite',
|
||||||
|
slowestDriftLeft: 'driftLeft 180s linear infinite',
|
||||||
|
slowTwinkle: 'twinkle 0.25s linear infinite alternate',
|
||||||
|
slowerTwinkle: 'twinkle 0.5s linear infinite alternate',
|
||||||
|
wobble: 'wobble 2s ease-in-out infinite alternate',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
animateInX: {
|
||||||
|
'0%': {
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateX(100%)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '1',
|
||||||
|
transform: 'translateX(0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animateOutX: {
|
||||||
|
'0%': {
|
||||||
|
maxHeight: '200px',
|
||||||
|
opacity: '1',
|
||||||
|
transform: 'translateX(0%)',
|
||||||
|
},
|
||||||
|
'70%': {
|
||||||
|
maxHeight: '200px',
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateX(100%)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
maxHeight: '0',
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateX(100%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
animateInY: {
|
||||||
|
'0%': {
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateY(100%)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '1',
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animateOutY: {
|
||||||
|
'0%': {
|
||||||
|
opacity: '1',
|
||||||
|
transform: 'translateY(0%)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
driftLeft: {
|
||||||
|
'0%': {
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateX(0)',
|
||||||
|
},
|
||||||
|
'5%, 95%': {
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '0',
|
||||||
|
transform: 'translateX(-120vw)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
float: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'translateY(-3%)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translateY(3%)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
floatUp: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'translateX(0) translateY(0)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'translateX(10vw) translateY(-110vh) rotate(10deg)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
forceFieldSmallInner: {
|
||||||
|
'0%': {
|
||||||
|
opacity: String(innerRingStartOpacity),
|
||||||
|
strokeWidth: '5px',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: String(innerRingEndOpacity),
|
||||||
|
strokeWidth: '20px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
forceFieldSmallOuter: {
|
||||||
|
'0%': {
|
||||||
|
opacity: String(outerRingStartOpacity),
|
||||||
|
strokeWidth: '10px',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: String(outerRingEndOpacity),
|
||||||
|
strokeWidth: '30px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
forceFieldInner: {
|
||||||
|
'0%': {
|
||||||
|
opacity: String(innerRingStartOpacity),
|
||||||
|
strokeWidth: '10px',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: String(innerRingEndOpacity),
|
||||||
|
strokeWidth: '60px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
forceFieldOuter: {
|
||||||
|
'0%': {
|
||||||
|
opacity: String(outerRingStartOpacity),
|
||||||
|
strokeWidth: '20px',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: String(outerRingEndOpacity),
|
||||||
|
strokeWidth: '100px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
rotatingSphere: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'rotate(0)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'rotate(359deg)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
twinkle: {
|
||||||
|
'0%': {
|
||||||
|
opacity: '0.9',
|
||||||
|
},
|
||||||
|
'50%': {
|
||||||
|
opacity: '0.8',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
wobble: {
|
||||||
|
'0%': {
|
||||||
|
transform: 'rotate(-3deg)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
transform: 'rotate(3deg)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
plugin(function ({ addBase, theme }) {
|
||||||
|
addBase({
|
||||||
|
'*': {
|
||||||
|
scrollbarColor: `${theme('colors.accentColor')} transparent`,
|
||||||
|
},
|
||||||
|
'*::-webkit-scrollbar': {
|
||||||
|
height: theme('spacing.2'),
|
||||||
|
width: theme('spacing.2'),
|
||||||
|
},
|
||||||
|
'*::-webkit-scrollbar-track': {
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
'*::-webkit-scrollbar-thumb': {
|
||||||
|
background: theme('colors.accentColor'),
|
||||||
|
borderRadius: theme('spacing.8'),
|
||||||
|
},
|
||||||
|
'a, button, input, textarea': {
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
formsPlugin,
|
||||||
|
typographyPlugin,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
export default config
|
27
apps/transfers/frontend/tsconfig.json
Normal file
27
apps/transfers/frontend/tsconfig.json
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"target": "ES2017"
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
Loading…
Reference in a new issue