1
0
Fork 0

new send/preview flow

This commit is contained in:
Matthias Kretschmann 2023-11-02 21:47:14 +00:00
parent 52dba93d71
commit 498e5a1730
Signed by: m
GPG Key ID: 606EEEF3C479A91F
23 changed files with 333 additions and 105 deletions

View File

@ -8,7 +8,7 @@ const year = new Date().getFullYear()
const { name, url, github } = config.author
---
<footer role="contentinfo" class={styles.footer}>
<footer role="contentinfo" class={styles.footer} id="footer">
<Vcard />
<section class={styles.copyright}>
<p>

View File

@ -6,7 +6,7 @@ import { Logo } from '@images/components'
import styles from './index.module.css'
---
<header class={styles.header} aria-label="Header">
<header class={styles.header} aria-label="Header" id="header">
<div class={styles.headerContent}>
<a href="/" class={styles.title}>
<Logo class={styles.logo} viewBox="0 0 191 36" /> kremalicious

View File

@ -0,0 +1,16 @@
.loader {
will-change: transform;
animation: spin 1s linear infinite;
width: 18px !important;
height: 18px !important;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(359deg);
}
}

View File

@ -0,0 +1,8 @@
import styles from './Loader.module.css'
import { Loader as LoaderIcon } from '@images/components/react'
export function Loader() {
// TODO: fix React props for generated SVG components for class/className
//@ts-expect-error-next-line
return <LoaderIcon className={styles.loader} />
}

View File

@ -0,0 +1 @@
export * from './Loader'

View File

@ -1,11 +1,9 @@
.web3 {
margin-left: auto;
margin-right: auto;
margin: calc(var(--spacer) / 2) auto calc(var(--spacer) / 4) auto;
max-width: 25rem;
width: 100%;
text-align: center;
min-height: 165px;
margin-top: calc(var(--spacer) / 4);
}
.rainbowkit button > div {
@ -56,11 +54,18 @@
}
.disclaimer {
color: var(--text-color-light);
font-size: var(--font-size-small);
margin-top: calc(var(--spacer) / 3);
margin-top: calc(var(--spacer) / 2);
margin-bottom: calc(var(--spacer) / 6);
}
.disclaimer code {
background: none;
color: var(--text-color);
padding-left: 2px;
}
.message {
font-size: var(--font-size-small);
position: relative;

View File

@ -5,34 +5,35 @@ import { ConnectButton } from '@rainbow-me/rainbowkit'
import { InputGroup } from '../Input'
import styles from './index.module.css'
import { useStore } from '@nanostores/react'
import { $selectedToken } from '@features/Web3/stores/selectedToken'
import { $selectedToken, $isInitSend } from '@features/Web3/stores'
import siteConfig from '@config/blog.config'
import { Send } from '../Send/Send'
import { Send } from '../Send'
export default function Web3Form(): ReactElement {
const { address: account } = useAccount()
const selectedToken = useStore($selectedToken)
const isInitSend = useStore($isInitSend)
const [amount, setAmount] = useState('')
const [debouncedAmount] = useDebounce(amount, 500)
const [initSend, setInitSend] = useState(false)
const isDisabled = !account
// reset amount whenever token changes
useEffect(() => {
if (!selectedToken) return
setAmount('')
}, [selectedToken])
return initSend ? (
<Send amount={debouncedAmount} setInitSend={setInitSend} />
return isInitSend ? (
<Send amount={debouncedAmount} />
) : (
<form
className={styles.web3}
onSubmit={(e) => {
e.preventDefault()
if (debouncedAmount !== '' || debouncedAmount === '0') return
setInitSend(true)
if (debouncedAmount === '' || debouncedAmount === '0') return
$isInitSend.set(true)
}}
>
<>
@ -42,7 +43,6 @@ export default function Web3Form(): ReactElement {
<InputGroup
amount={amount}
setAmount={setAmount}
setInitSend={setInitSend}
isDisabled={isDisabled}
/>
<div className={styles.disclaimer}>

View File

@ -64,12 +64,12 @@
border-top-left-radius: 0;
border-top-right-radius: 0;
border-color: var(--link-color-hover);
padding: 0 calc(var(--spacer) / 4);
padding: 0 calc(var(--spacer) / 2);
}
@media (min-width: 40rem) {
.submit {
width: fit-content;
width: 115px;
border-top-right-radius: var(--border-radius);
border-top-left-radius: 0;
border-bottom-left-radius: 0;

View File

@ -3,17 +3,16 @@ import Input from '@components/Input'
import { Conversion } from '../Conversion'
import styles from './InputGroup.module.css'
import { TokenSelect } from '../TokenSelect'
import { $isInitSend } from '@features/Web3/stores'
export function InputGroup({
amount,
isDisabled,
setAmount,
setInitSend
setAmount
}: {
amount: string
isDisabled: boolean
setAmount: React.Dispatch<React.SetStateAction<string>>
setInitSend: React.Dispatch<React.SetStateAction<boolean>>
}): ReactElement {
return (
<>
@ -36,9 +35,9 @@ export function InputGroup({
<button
className={`${styles.submit} btn btn-primary`}
disabled={isDisabled || !amount}
onClick={() => setInitSend(true)}
onClick={() => $isInitSend.set(true)}
>
Make it rain
Preview
</button>
</div>
<Conversion amount={amount} />

View File

@ -0,0 +1,24 @@
.send {
margin-top: calc(var(--spacer) / 2);
}
.title {
text-align: center;
}
.actions {
display: flex;
justify-content: center;
}
.actions button {
line-height: 1;
}
.actions button:first-child {
margin-right: var(--spacer);
width: 115px;
height: 50px;
padding-top: 0;
padding-bottom: 0;
}

View File

@ -1,27 +1,27 @@
import { useStore } from '@nanostores/react'
import { $selectedToken } from '@features/Web3/stores/selectedToken'
import { useNetwork, useEnsAddress } from 'wagmi'
import siteConfig from '@config/blog.config'
import { useState, type FormEvent, useEffect } from 'react'
import { prepareTransaction } from './prepareTransaction'
import { sendTransaction } from './sendTransaction'
import { useStore } from '@nanostores/react'
import { useNetwork, useEnsAddress, useEnsName } from 'wagmi'
import type {
SendTransactionArgs,
WriteContractPreparedArgs
} from 'wagmi/actions'
import { formatEther } from 'viem'
import { $selectedToken, $isInitSend } from '@features/Web3/stores'
import siteConfig from '@config/blog.config'
import { prepareTransaction, sendTransaction } from './actions'
import styles from './Send.module.css'
import { SendTable } from './SendTable'
import { Loader } from '@components/Loader'
export function Send({
amount,
setInitSend
}: {
amount: string
setInitSend: (initSend: boolean) => void
}) {
export function Send({ amount }: { amount: string }) {
const { ens } = siteConfig.author.ether
const { chain } = useNetwork()
const selectedToken = useStore($selectedToken)
const { data: to } = useEnsAddress({
name: siteConfig.author.ether.ens,
// Always resolve to address from ENS name and vice versa
// so nobody has to trust my config values.
const { data: to } = useEnsAddress({ name: ens, chainId: 1 })
const { data: ensResolved } = useEnsName({
address: to as `0x${string}` | undefined,
chainId: 1
})
@ -29,6 +29,7 @@ export function Send({
SendTransactionArgs | WriteContractPreparedArgs
>()
const [txHash, setTxHash] = useState<string>()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
async function init() {
@ -47,27 +48,47 @@ export function Send({
async function handleSend(event: FormEvent<HTMLButtonElement>) {
event?.preventDefault()
const result = await sendTransaction(selectedToken, txConfig)
setTxHash(result?.hash)
try {
setIsLoading(true)
const result = await sendTransaction(selectedToken, txConfig)
setTxHash(result?.hash)
setIsLoading(false)
} catch (error: unknown) {
console.error((error as Error).message)
setIsLoading(false)
}
}
const value =
(txConfig as SendTransactionArgs)?.value ||
(txConfig as WriteContractPreparedArgs)?.request?.args[1] ||
'0'
const displayAmountFromConfig = formatEther(value)
console.log(txHash)
return (
<>
<div>
<p>You are about to send</p>
<p>
{displayAmountFromConfig} {selectedToken?.symbol} to <code>{to}</code>{' '}
on {chain?.name}
</p>
<button onClick={(e) => handleSend(e)}>Confirm</button>
<button onClick={() => setInitSend(false)}>Cancel</button>
<div className={styles.send}>
{/* <h5 className={styles.title}>You are sending</h5> */}
<SendTable
to={to}
ensResolved={ensResolved}
txConfig={txConfig}
isDisabled={isLoading}
/>
<footer className={styles.actions}>
<button
onClick={(e) => handleSend(e)}
className="btn btn-primary"
disabled={isLoading}
>
{isLoading ? <Loader /> : 'Make it rain'}
</button>
<button
onClick={() => $isInitSend.set(false)}
className="link"
disabled={isLoading}
>
Cancel
</button>
</footer>
</div>
</>
)

View File

@ -0,0 +1,40 @@
.amount,
.network,
.to {
/* font-weight: var(--font-weight-bold); */
}
.to {
display: block;
word-break: break-all;
background: none;
padding: 0;
}
.to:last-child {
padding-left: 14px;
font-size: var(--font-size-mini);
}
.to:last-child::first-letter {
margin-left: -14px;
}
.table {
/* max-width: 386px; */
margin-bottom: calc(var(--spacer) / 1.5);
}
table[aria-disabled='true'] {
opacity: 0.5;
pointer-events: none;
}
.table td {
padding: calc(var(--spacer) / 4);
}
.label {
color: var(--text-color-light);
vertical-align: top;
}

View File

@ -0,0 +1,66 @@
import { formatEther } from 'viem'
import { useNetwork } from 'wagmi'
import type {
SendTransactionArgs,
WriteContractPreparedArgs
} from 'wagmi/actions'
import styles from './SendTable.module.css'
import { useStore } from '@nanostores/react'
import { $selectedToken } from '@features/Web3/stores'
export function SendTable({
to,
ensResolved,
txConfig,
isDisabled
}: {
to: `0x${string}` | null | undefined
ensResolved: string | null | undefined
txConfig: SendTransactionArgs | WriteContractPreparedArgs | undefined
isDisabled: boolean
}) {
const { chain } = useNetwork()
const selectedToken = useStore($selectedToken)
// Derive display values in preview from actual tx config
// instead from our form stores
const value =
(txConfig as SendTransactionArgs)?.value ||
(txConfig as WriteContractPreparedArgs)?.request?.args?.[1] ||
'0'
const displayAmountFromConfig = formatEther(value as bigint)
return (
<table className={styles.table} aria-disabled={isDisabled}>
<tbody>
<tr>
<td className={styles.label}>Sending</td>
<td>
<span className={styles.amount}>
{displayAmountFromConfig} {selectedToken?.symbol}
</span>
</td>
</tr>
<tr>
<td className={styles.label}>on</td>
<td>
<span className={styles.network}>{chain?.name}</span>
</td>
</tr>
{/* <tr>
<td>From</td>
<td>
<code className={styles.from}>{from}</code>
</td>
</tr> */}
<tr>
<td className={styles.label}>to</td>
<td title={`${ensResolved} successfully resolved to ${to}`}>
<code className={styles.to}>{ensResolved}</code>
<code className={styles.to}>{`${to}`}</code>
</td>
</tr>
</tbody>
</table>
)
}

View File

@ -0,0 +1,2 @@
export * from './prepareTransaction'
export * from './sendTransaction'

View File

@ -0,0 +1,34 @@
import type { GetToken } from '@features/Web3/stores/tokens'
import { parseEther, parseUnits } from 'viem'
import {
prepareSendTransaction,
prepareWriteContract,
type SendTransactionArgs,
type WriteContractPreparedArgs
} from 'wagmi/actions'
import { abiErc20Transfer } from '../abiErc20Transfer'
export async function prepareTransaction(
selectedToken: GetToken | undefined,
amount: string | undefined,
to: `0x${string}` | null | undefined,
chainId: number | undefined
) {
if (!chainId || !to || !amount || !selectedToken || !selectedToken?.address)
return
const isNative = selectedToken.address === '0x0'
const setupNative = { chainId, to, value: parseEther(amount) }
const setupErc20 = {
address: selectedToken.address,
abi: abiErc20Transfer,
functionName: 'transfer',
args: [to, parseUnits(amount, selectedToken.decimals || 18)]
}
const config = isNative
? ((await prepareSendTransaction(setupNative)) as SendTransactionArgs)
: ((await prepareWriteContract(setupErc20)) as WriteContractPreparedArgs)
return config
}

View File

@ -1,35 +0,0 @@
import type { GetToken } from '@features/Web3/stores/tokens'
import { parseEther, parseUnits } from 'viem'
import {
prepareSendTransaction,
prepareWriteContract,
type SendTransactionArgs,
type WriteContractPreparedArgs
} from 'wagmi/actions'
import { abiErc20Transfer } from './abiErc20Transfer'
export async function prepareTransaction(
selectedToken: GetToken | undefined,
amount: string | undefined,
to: `0x${string}` | null | undefined,
chainId: number | undefined
) {
if (!chainId || !to || !amount || !selectedToken) return
const isNative = selectedToken?.address === '0x0'
const config = isNative
? ((await prepareSendTransaction({
chainId,
to,
value: parseEther(amount)
})) as SendTransactionArgs)
: ((await prepareWriteContract({
address: selectedToken?.address,
abi: abiErc20Transfer,
functionName: 'transfer',
args: [to, parseUnits(amount, selectedToken?.decimals || 18)]
})) as WriteContractPreparedArgs)
return config
}

View File

@ -1,8 +0,0 @@
export type SendFormData = {
data: { hash: `0x${string}` }
send: () => Promise<void>
isLoading: boolean
isSuccess: boolean
isError: boolean
error: Error | null
}

View File

@ -0,0 +1,3 @@
export * from './tokens'
export * from './selectedToken'
export * from './isInitSend'

View File

@ -0,0 +1,3 @@
import { atom } from 'nanostores'
export const $isInitSend = atom<boolean>(false)

View File

@ -67,6 +67,35 @@ import CodeCopy from '@components/CopyCode.astro'
}
</style>
<style is:global>
html.isInitSend #header,
html.isInitSend #footer,
html.isInitSend section:not(.isInitSend),
html.isInitSend h2,
html.isInitSend h3 {
opacity: 0.1;
pointer-events: none;
transition: opacity 0.2s ease-out;
}
html.isInitSend #web3 {
opacity: 1;
pointer-events: all;
}
</style>
<script>
import { $isInitSend } from '@features/Web3/stores'
$isInitSend.subscribe((value) => {
const html = document.querySelector('html')
value
? html?.classList.add('isInitSend')
: html?.classList.remove('isInitSend')
})
</script>
<LayoutBase title="Say Thanks" pageTitle="Say Thanks">
<!-- <BackButton /> -->
<div class="grid">
@ -78,7 +107,7 @@ import CodeCopy from '@components/CopyCode.astro'
can send me some Ether, ERC-20 token, or Bitcoin.
</section>
<section class="section highlight">
<section class="section highlight" id="web3">
<h4 class="titleCoin"><Wallet /> Web3 Wallet</h4>
<Web3 client:load />
</section>
@ -91,12 +120,27 @@ import CodeCopy from '@components/CopyCode.astro'
<div>
<h3 class="subTitle">Sponsor</h3>
<section class="section">You can also sponsor me on GitHub.</section>
<a href="https://github.com/sponsors/kremalicious/">
<img
src="https://img.shields.io/static/v1?label=Sponsor%20On%20GitHub&labelColor=%2343a699&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86&style=for-the-badge"
/>
</a>
<section class="section">
<p>You can also sponsor me on GitHub.</p>
<a href="https://github.com/sponsors/kremalicious/">
<img
src="https://img.shields.io/static/v1?label=Sponsor%20On%20GitHub&labelColor=%2343a699&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86&style=for-the-badge"
/>
</a>
</section>
<h3 class="subTitle">Hire Me</h3>
<section class="section">
<p>
Available for contract work to solve your design, front-end, and web3
problems.
</p>
<p>
Get in touch on <a href="https://matthiaskretschmann.com"
>my portfolio</a
>.
</p>
</section>
</div>
</div>
</LayoutBase>

View File

@ -33,9 +33,11 @@ a.btn {
transition: none;
}
/* // Disabled State */
/* Disabled State */
.btn.disabled,
.btn[disabled] {
.btn[disabled],
button:disabled,
.btn:disabled {
/* TODO: cursor & pointer values can't be used together */
cursor: not-allowed;
pointer-events: none;

View File

@ -319,7 +319,6 @@ table {
th {
text-align: left;
border-top: 1px solid var(--border-color);
}
th,
@ -331,6 +330,10 @@ td {
border-bottom: 1px solid var(--border-color);
}
tr:last-child td {
border-bottom: none;
}
/* Selection
///////////////////////////////////// */