mirror of
https://github.com/kremalicious/blog.git
synced 2024-11-22 01:46:51 +01:00
commit
5a431bce58
@ -9,7 +9,10 @@ export default {
|
||||
mastodon: 'https://mas.to/@krema',
|
||||
github: 'https://github.com/kremalicious',
|
||||
bitcoin: '171qDmKEXm9YBgBLXyGjjPvopP5o9htQ1V',
|
||||
ether: '0xf50F267b5689b005FE107cfdb34619f24c014457'
|
||||
ether: {
|
||||
ens: 'krema.eth',
|
||||
address: '0xf50F267b5689b005FE107cfdb34619f24c014457'
|
||||
}
|
||||
},
|
||||
rss: '/feed.xml',
|
||||
jsonfeed: '/feed.json',
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npm run validate
|
||||
npx lint-staged
|
||||
|
@ -5,3 +5,4 @@ PUBLIC_UMAMI_SCRIPT_URL=xxx
|
||||
PUBLIC_UMAMI_WEBSITE_ID=xxx
|
||||
PUBLIC_INFURA_ID=xxx
|
||||
PUBLIC_WALLETCONNECT_ID="xxx"
|
||||
PUBLIC_WEB3_API_URL="xxx"
|
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
github: kremalicious
|
||||
patreon: kremalicious
|
||||
custom: ['https://kremalicious.com/thanks']
|
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@ -16,6 +16,7 @@ env:
|
||||
PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.GATSBY_UMAMI_WEBSITE_ID }}
|
||||
PUBLIC_INFURA_ID: ${{ secrets.GATSBY_INFURA_ID }}
|
||||
PUBLIC_WALLETCONNECT_ID: ${{ secrets.GATSBY_WALLETCONNECT_ID }}
|
||||
PUBLIC_WEB3_API_URL: ${{ secrets.PUBLIC_WEB3_API_URL }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
45
README.md
45
README.md
@ -18,10 +18,10 @@
|
||||
- [🎉 Features](#-features)
|
||||
- [🌅 Image handling](#-image-handling)
|
||||
- [🎆 EXIF extraction](#-exif-extraction)
|
||||
- [💰 Cryptocurrency donation via Web3/MetaMask](#-cryptocurrency-donation-via-web3metamask)
|
||||
- [💰 Cryptocurrency donation via Web3 browser wallets](#-cryptocurrency-donation-via-web3-browser-wallets)
|
||||
- [🔍 Search](#-search)
|
||||
- [🕸 Related Posts](#-related-posts)
|
||||
- [📝 GitHub changelog rendering](#-github-changelog-rendering)
|
||||
- [📝 GitHub Changelog Rendering](#-github-changelog-rendering)
|
||||
- [🌗 Theme Switcher](#-theme-switcher)
|
||||
- [💎 SVG assets as components](#-svg-assets-as-components)
|
||||
- [astro-redirect-from](#astro-redirect-from)
|
||||
@ -46,7 +46,7 @@ The whole [blog](https://kremalicious.com) is a statically exported site built w
|
||||
|
||||
Styling happens through a combination of basic global styles and on components level either through CSS modules or CSS in `<style>` tags within Astro components.
|
||||
|
||||
Content lives under `content/` and Astro creates a content collection for each subfolder, which are then queried in components. Every post is a folder with a markdown file and all respective post assets colocated inside.
|
||||
Content lives under `content/` and Astro creates a content collection for each subfolder, which are then queried in components. Every post is a folder with a markdown file and all respective post assets co-located inside.
|
||||
|
||||
Retrieving content collections will enrich every post's frontmatter metadata, like extracting date and slug from the post folder name, or exif extraction for photos.
|
||||
|
||||
@ -75,15 +75,17 @@ If you want to know how this works, have a look at the respective files:
|
||||
- the `loadAndFormatCollection()` helper in [`src/lib/astro.ts`](src/lib/astro.ts)
|
||||
- output through [`src/components/Exif/`](src/components/Exif/)
|
||||
|
||||
### 💰 Cryptocurrency donation via Web3/MetaMask
|
||||
### 💰 Cryptocurrency donation via Web3 browser wallets
|
||||
|
||||
Lets visitors say thanks with Bitcoin or Ether. Uses [RainbowKit](https://www.rainbowkit.com) for wallet connection & [wagmi](https://wagmi.sh) for sending transactions via browser wallets.
|
||||
Lets visitors say thanks with Ether, any ERC-20, or Bitcoin. The Web3 wallet integration uses [RainbowKit](https://www.rainbowkit.com) for wallet connection, my own custom web3 API to fetch wallet token balances and metadata, and [wagmi](https://wagmi.sh) for sending transactions.
|
||||
|
||||
<img width="700" alt="screen shot 2018-10-14 at 22 03 57" src="https://user-images.githubusercontent.com/90316/46921544-1a512080-cffd-11e8-919f-d3e86dbd5cc5.png" />
|
||||
<img width="502" alt="Screenshot 2023-11-05 at 20 18 50" src="https://github.com/kremalicious/blog/assets/90316/7eadf4e9-6e98-4cf6-9639-aebf42ac0d4e">
|
||||
|
||||
If you want to know how this works, have a look at the respective components under
|
||||
<img width="487" alt="Screenshot 2023-11-05 at 20 20 04" src="https://github.com/kremalicious/blog/assets/90316/2421e64c-2d98-4e2a-a67a-ab1b5640bfb6">
|
||||
|
||||
- [`src/components/Donation/`](src/components/Donation/)
|
||||
If you want to know how this works, have a look at the respective feature under
|
||||
|
||||
- [`src/features/Web3/`](src/features/Web3/)
|
||||
|
||||
### 🔍 Search
|
||||
|
||||
@ -91,9 +93,9 @@ A global search is provided with fuse.js. Whenever search is opened, all posts m
|
||||
|
||||
<img width="700" alt="screen shot 2018-11-18 at 19 44 30" src="https://user-images.githubusercontent.com/90316/48676679-634f4400-eb6a-11e8-936d-293505d5c5d9.png">
|
||||
|
||||
If you want to know how this works, have a look at the respective components under
|
||||
If you want to know how this works, have a look at the respective feature under
|
||||
|
||||
- [`src/components/Search/`](src/components/Search/)
|
||||
- [`src/features/Search/`](src/features/Search/)
|
||||
|
||||
### 🕸 Related Posts
|
||||
|
||||
@ -105,7 +107,7 @@ If you want to know how this works, have a look at the respective component unde
|
||||
|
||||
- [`src/components/RelatedPosts/`](src/components/RelatedPosts/)
|
||||
|
||||
### 📝 GitHub changelog rendering
|
||||
### 📝 GitHub Changelog Rendering
|
||||
|
||||
Adds ability to show contents of a changelog, rendered from a `CHANGELOG.md` on GitHub from the given repository. The use case is to enhance release posts about projects hosted on GitHub. Makes use of the GitHub GraphQL API.
|
||||
|
||||
@ -124,24 +126,23 @@ See it live e.g. on [Matomo plugin for Gatsby](https://kremalicious.com/gatsby-p
|
||||
If you want to know how this works, have a look at the respective component under
|
||||
|
||||
- [`src/components/Changelog/`](src/components/Changelog/)
|
||||
- the `getRepo()` helper in [`src/lib/github.ts`](src/lib/github.ts)
|
||||
- the `getRepo()` helper in [`src/lib/github/github.ts`](src/lib/github/github.ts)
|
||||
|
||||
### 🌗 Theme Switcher
|
||||
|
||||
Includes a theme switcher which allows user to toggle between a light and a dark theme. Switching between them also happens automatically based on user's system preferences. Uses [nanostores](https://github.com/nanostores/nanostores) to share its state between components/frameworks.
|
||||
Includes a theme switcher which allows user to toggle between a light and a dark theme. Switching between them also happens automatically based on user's system preferences.
|
||||
|
||||
If you want to know how, have a look at the respective components:
|
||||
|
||||
- [`src/components/ThemeSwitch/`](src/components/ThemeSwitch/)
|
||||
- [`src/stores/theme.ts`](src/stores/theme.ts)
|
||||
|
||||
### 💎 SVG assets as components
|
||||
|
||||
All SVG assets under `src/images/` and from select iconset dependencies are converted to Astro components before building the site. Compiled components are placed under `src/images/components/` and all include the cleaned SVGs as inline HTML.
|
||||
All SVG assets under `src/images/` and from select iconset dependencies are converted to Astro & React components before building the site. Compiled components are placed under `src/images/components/` and all include the cleaned SVGs as inline HTML.
|
||||
|
||||
All SVGs can then be imported from `@images/components` in all Astro components.
|
||||
All SVGs can then be imported from `@images/components` in all Astro or React components.
|
||||
|
||||
If you want to know how this works, have a look at the respective files:
|
||||
If you want to know how this works, have a look at the script:
|
||||
|
||||
- [`scripts/create-icons/`](scripts/create-icons/)
|
||||
|
||||
@ -153,6 +154,13 @@ For all post slugs defined in a `redirect_from` frontmatter key, redirects will
|
||||
|
||||
### RSS & JSON feeds
|
||||
|
||||
Generates rss & json feeds upon build time.
|
||||
|
||||
If you want to know how this works, have a look at the respective files:
|
||||
|
||||
- [`src/pages/feed.json.ts`](src/pages/feed.json.ts)
|
||||
- [`src/pages/feed.xml.ts`](src/pages/feed.xml.ts)
|
||||
|
||||
## ✨ Development
|
||||
|
||||
```bash
|
||||
@ -201,6 +209,9 @@ To run all unit tests:
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
|
||||
# watch mode
|
||||
npm run test:unit:watch
|
||||
```
|
||||
|
||||
For End-to-End integration testing, ideally run against the production build:
|
||||
|
980
package-lock.json
generated
980
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
39
package.json
39
package.json
@ -15,6 +15,7 @@
|
||||
"typecheck": "npm run typecheck:astro && npm run typecheck:tsc",
|
||||
"prebuild": "run-p --silent --continue-on-error create:symlinks create:icons move:downloads",
|
||||
"test:unit": "vitest run --config './test/vitest.config.ts' --coverage",
|
||||
"test:unit:watch": "vitest watch --config './test/vitest.config.ts' --coverage",
|
||||
"test:e2e": "playwright test --config './test/playwright.config.ts'",
|
||||
"lint": "run-p --silent lint:js lint:css lint:md",
|
||||
"lint:js": "eslint --ignore-path .gitignore './{src,test,scripts}/**/*.{ts,tsx,astro,mjs,js,cjs}'",
|
||||
@ -28,17 +29,37 @@
|
||||
"create:symlinks": "./scripts/create-symlinks.sh",
|
||||
"move:downloads": "ts-node --esm scripts/move-downloads.ts",
|
||||
"prepare": "husky install .config/husky",
|
||||
"validate": "run-p --silent typecheck lint"
|
||||
"lint:staged": "lint-staged"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,astro}": [
|
||||
"prettier --write",
|
||||
"eslint"
|
||||
],
|
||||
"*.css": [
|
||||
"stylelint --config '.config/.stylelintrc.json' --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"**/*.json": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.md": [
|
||||
"markdownlint --config '.config/.markdownlint.json'",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.2.1",
|
||||
"@astrojs/check": "^0.3.0",
|
||||
"@astrojs/react": "^3.0.4",
|
||||
"@astrojs/rss": "^3.0.0",
|
||||
"@astrojs/sitemap": "^3.0.2",
|
||||
"@nanostores/query": "^0.2.4",
|
||||
"@astrojs/sitemap": "^3.0.3",
|
||||
"@coingecko/cryptoformat": "^0.7.0",
|
||||
"@nanostores/persistent": "^0.9.1",
|
||||
"@nanostores/query": "^0.2.8",
|
||||
"@nanostores/react": "^0.7.1",
|
||||
"@rainbow-me/rainbowkit": "^1.1.3",
|
||||
"astro": "3.3.4",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@rainbow-me/rainbowkit": "^1.2.0",
|
||||
"astro": "3.4.2",
|
||||
"astro-expressive-code": "^0.26.2",
|
||||
"astro-redirect-from": "^1.0.4",
|
||||
"date-fns": "^2.30.0",
|
||||
@ -46,15 +67,15 @@
|
||||
"fast-exif": "^2.0.1",
|
||||
"feather-icons": "^4.29.1",
|
||||
"fraction.js": "^4.3.7",
|
||||
"fuse.js": "^6.6.2",
|
||||
"fuse.js": "^7.0.0",
|
||||
"motion": "^10.16.4",
|
||||
"nanostores": "^0.9.4",
|
||||
"pigeon-maps": "^0.21.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"slugify": "^1.6.6",
|
||||
"use-debounce": "^9.0.4",
|
||||
"viem": "^1.16.6",
|
||||
"swr": "^2.2.4",
|
||||
"viem": "^1.18.2",
|
||||
"wagmi": "^1.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -9,7 +9,7 @@ afterAll(() => {
|
||||
fs.rm(path.resolve(__dirname, 'tmp'), { recursive: true })
|
||||
})
|
||||
|
||||
test('should generate Astro components from SVG files', async () => {
|
||||
test('should generate Astro & React components from SVG files', async () => {
|
||||
// Act
|
||||
await generateIcons(distDir)
|
||||
|
||||
@ -27,13 +27,22 @@ test('should generate Astro components from SVG files', async () => {
|
||||
throw new Error('Props.d.ts does not exist')
|
||||
}
|
||||
|
||||
// Assert: Check if an example Astro component exists
|
||||
const exampleComponentPath = path.join(distDir, 'Bitcoin.astro')
|
||||
// Assert: Check if an example Astro & React component exists
|
||||
const exampleComponentPathAstro = path.join(distDir, 'Bitcoin.astro')
|
||||
const exampleComponentPathReact = path.join(distDir, 'react', 'Bitcoin.tsx')
|
||||
try {
|
||||
await fs.stat(exampleComponentPath)
|
||||
await fs.stat(exampleComponentPathAstro)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Example Astro component does not exist: ${exampleComponentPath}`
|
||||
`Example Astro component does not exist: ${exampleComponentPathAstro}`
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.stat(exampleComponentPathReact)
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Example React component does not exist: ${exampleComponentPathReact}`
|
||||
)
|
||||
}
|
||||
})
|
||||
|
@ -6,7 +6,9 @@ import fs from 'node:fs/promises'
|
||||
import ps from 'node:path/posix'
|
||||
import ora from 'ora'
|
||||
import chalk from 'chalk'
|
||||
import { toAstroComponent, toInnerSvg } from './svg.ts'
|
||||
import { toInnerSvg } from './svg.ts'
|
||||
import { toAstroComponent } from './toAstroComponent.ts'
|
||||
import { toReactComponent } from './toReactComponent.ts'
|
||||
|
||||
// Current directory.
|
||||
const currentDir = ps.resolve('.')
|
||||
@ -31,15 +33,21 @@ export async function generateIcons(distDir: string) {
|
||||
// clean the distribution directory
|
||||
await fs.rm(distDir, { force: true, recursive: true })
|
||||
await fs.mkdir(distDir, { recursive: true })
|
||||
await fs.mkdir(`${distDir}/react`, { recursive: true })
|
||||
|
||||
// copy the attribute typings file
|
||||
await fs.copyFile(
|
||||
ps.resolve(currentDir, 'scripts/create-icons/Props.d.ts'),
|
||||
ps.resolve(distDir, 'Props.d.ts')
|
||||
)
|
||||
await fs.copyFile(
|
||||
ps.resolve(currentDir, 'scripts/create-icons/Props.d.ts'),
|
||||
ps.resolve(`${distDir}/react`, 'Props.d.ts')
|
||||
)
|
||||
|
||||
// convert the SVG files into Astro components
|
||||
// convert the SVG files into Astro & React components
|
||||
let contentOfIndexJS = '// @ts-nocheck\n'
|
||||
let contentOfIndexReactJS = '// @ts-nocheck\n'
|
||||
|
||||
for (const src of srcDirs) {
|
||||
for (let filepath of await fs.readdir(src, { encoding: 'utf8' })) {
|
||||
@ -83,16 +91,33 @@ export async function generateIcons(distDir: string) {
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// write the react component to a file
|
||||
await fs.writeFile(
|
||||
ps.resolve(`${distDir}/react`, `${baseName}.tsx`),
|
||||
toReactComponent(innerSVG, title),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
// add the astro component export to the main entry `index.ts` file
|
||||
contentOfIndexJS += `\nexport { default as ${baseName} } from './${baseName}.astro'`
|
||||
|
||||
// add the react component export to the main entry `react/index.ts` file
|
||||
contentOfIndexReactJS += `\nexport { Icon as ${baseName} } from './${baseName}.tsx'`
|
||||
|
||||
icons.push({ name, baseName, title })
|
||||
}
|
||||
}
|
||||
|
||||
// write the main entry `index.ts` file
|
||||
// write the main Astro entry `index.ts` file
|
||||
await fs.writeFile(ps.resolve(distDir, 'index.ts'), contentOfIndexJS, 'utf8')
|
||||
|
||||
// write the main React entry `react/index.ts` file
|
||||
await fs.writeFile(
|
||||
ps.resolve(`${distDir}/react`, 'index.ts'),
|
||||
contentOfIndexReactJS,
|
||||
'utf8'
|
||||
)
|
||||
|
||||
spinner.succeed(
|
||||
`${chalk.bold('[create-icons]')} Generated ${
|
||||
icons.length
|
||||
|
@ -1,43 +1,5 @@
|
||||
import { optimize as optimizeSVGNative } from 'svgo'
|
||||
|
||||
export const toAstroComponent = (innerSVG: string, title: string) => `---
|
||||
import type { Props } from './Props.ts';
|
||||
export type { Props };
|
||||
|
||||
let {
|
||||
size = '24px',
|
||||
title,
|
||||
width = size,
|
||||
height = size,
|
||||
...props
|
||||
}: Props = {
|
||||
'fill': 'none',
|
||||
'title': '${title}',
|
||||
'viewBox': '0 0 24 24',
|
||||
...Astro.props
|
||||
}
|
||||
|
||||
const toAttributeSize = (size: number | string) =>
|
||||
String(size).replace(/(?<=[0-9])x$/, 'em')
|
||||
|
||||
size = toAttributeSize(size)
|
||||
width = toAttributeSize(width)
|
||||
height = toAttributeSize(height)
|
||||
---
|
||||
<style is:global>
|
||||
.icon {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
stroke: currentcolor;
|
||||
stroke-width: var(--border-width);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
</style>
|
||||
<svg {width} {height} {...props} class="icon">{title ? (<title>{title}</title>) : ''}${innerSVG}</svg>`
|
||||
|
||||
export const toInnerSvg = (input: string) =>
|
||||
optimizeSVGNative(input, {
|
||||
plugins: [
|
||||
|
39
scripts/create-icons/toAstroComponent.ts
Normal file
39
scripts/create-icons/toAstroComponent.ts
Normal file
@ -0,0 +1,39 @@
|
||||
export const toAstroComponent = (innerSVG: string, title: string) => `---
|
||||
import type { Props } from './Props.d.ts';
|
||||
export type { Props };
|
||||
|
||||
let {
|
||||
size = '24px',
|
||||
title,
|
||||
width = size,
|
||||
height = size,
|
||||
...props
|
||||
}: Props = {
|
||||
'fill': 'none',
|
||||
'title': '${title}',
|
||||
'viewBox': '0 0 24 24',
|
||||
...Astro.props
|
||||
}
|
||||
|
||||
const toAttributeSize = (size: number | string) =>
|
||||
String(size).replace(/(?<=[0-9])x$/, 'em')
|
||||
|
||||
size = toAttributeSize(size)
|
||||
width = toAttributeSize(width)
|
||||
height = toAttributeSize(height)
|
||||
---
|
||||
<style is:global>
|
||||
.icon {
|
||||
width: .85em;
|
||||
height: .85em;
|
||||
stroke: currentcolor;
|
||||
stroke-width: var(--border-width);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
vertical-align: baseline;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
<svg width={width} height={height} {...props} class="icon">{title ? (<title>{title}</title>) : ''}${innerSVG}</svg>`
|
35
scripts/create-icons/toReactComponent.ts
Normal file
35
scripts/create-icons/toReactComponent.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export const toReactComponent = (innerSVG: string, title: string) => `
|
||||
import type { Props } from './Props.d.ts';
|
||||
|
||||
export function Icon(props: Props) {
|
||||
let {
|
||||
size = '24px',
|
||||
title,
|
||||
width = size,
|
||||
height = size
|
||||
}: Props = {
|
||||
'title': '${title}',
|
||||
...props
|
||||
}
|
||||
|
||||
const toAttributeSize = (size: number | string) =>
|
||||
String(size).replace(/(?<=[0-9])x$/, 'em')
|
||||
|
||||
size = toAttributeSize(size)
|
||||
width = toAttributeSize(width)
|
||||
height = toAttributeSize(height)
|
||||
|
||||
const style = {
|
||||
width: '1em',
|
||||
height: '1em',
|
||||
stroke: 'currentcolor',
|
||||
strokeWidth: 'var(--border-width)',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
fill: 'none',
|
||||
verticalAlign: 'baseline'
|
||||
}
|
||||
|
||||
return <svg width={width} height={height} fill="none" viewBox="0 0 24 24" {...props} style={style as any}>{title ? (<title>{title}</title>) : ''}${innerSVG}</svg>
|
||||
}
|
||||
`
|
29
src/components/CopyCode.astro
Normal file
29
src/components/CopyCode.astro
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
import Copy from '@components/Copy.astro'
|
||||
|
||||
type Props = {
|
||||
address: string
|
||||
}
|
||||
|
||||
const { address }: Props = Astro.props
|
||||
---
|
||||
|
||||
<style>
|
||||
.code {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.code code {
|
||||
display: block;
|
||||
padding: calc(var(--spacer) / 2);
|
||||
padding-right: 2rem;
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="code">
|
||||
<code>{address}</code>
|
||||
<Copy text={address} />
|
||||
</div>
|
@ -1,42 +0,0 @@
|
||||
---
|
||||
import Copy from '@components/Copy.astro'
|
||||
|
||||
type Props = {
|
||||
address: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const { address, title }: Props = Astro.props
|
||||
---
|
||||
|
||||
<style>
|
||||
.coin {
|
||||
margin-top: var(--spacer);
|
||||
}
|
||||
|
||||
.titleCoin {
|
||||
margin-bottom: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.code {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.code code {
|
||||
display: block;
|
||||
padding: calc(var(--spacer) / 2);
|
||||
padding-right: 2rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="coin">
|
||||
<h4 class="titleCoin">{title}</h4>
|
||||
<div class="code">
|
||||
<code>{address}</code>
|
||||
<Copy text={address} />
|
||||
</div>
|
||||
</div>
|
@ -1,16 +0,0 @@
|
||||
import Web3Donation from '@components/Donation/Web3Donation'
|
||||
import config from '@config/blog.config'
|
||||
import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
|
||||
import { WagmiConfig } from 'wagmi'
|
||||
import { wagmiConfig, chains, theme } from '@lib/rainbowkit'
|
||||
import type { ReactElement } from 'react'
|
||||
|
||||
export default function Web3(): ReactElement {
|
||||
return (
|
||||
<WagmiConfig config={wagmiConfig}>
|
||||
<RainbowKitProvider chains={chains} theme={theme}>
|
||||
<Web3Donation address={config.author.ether} />
|
||||
</RainbowKitProvider>
|
||||
</WagmiConfig>
|
||||
)
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
.alert {
|
||||
font-size: var(--font-size-small);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.alert:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.alert::after {
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
animation: ellipsis steps(4, end) 1s infinite;
|
||||
|
||||
/* ascii code for the ellipsis character */
|
||||
content: '\2026';
|
||||
width: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.success {
|
||||
composes: alert;
|
||||
color: green;
|
||||
}
|
||||
|
||||
.error {
|
||||
composes: alert;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.error::after,
|
||||
.success::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes ellipsis {
|
||||
to {
|
||||
width: 0.75rem;
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, it } from 'vitest'
|
||||
import Alert from './Alert'
|
||||
|
||||
describe('Alert', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(
|
||||
<Alert
|
||||
message={{ status: 'loading', text: 'Loading' }}
|
||||
transactionHash="0xxxx"
|
||||
/>
|
||||
)
|
||||
})
|
||||
})
|
@ -1,48 +0,0 @@
|
||||
import { type ReactElement } from 'react'
|
||||
import styles from './Alert.module.css'
|
||||
|
||||
export function getTransactionMessage(transactionHash?: string): {
|
||||
[key: string]: string
|
||||
} {
|
||||
return {
|
||||
transaction: `<a href="https://etherscan.io/tx/${transactionHash}" target="_blank">See your transaction on etherscan.io.</a>`,
|
||||
waitingForUser: 'Waiting for your confirmation',
|
||||
waitingConfirmation: 'Waiting for network confirmation, hang on',
|
||||
success: 'Confirmed. You are awesome, thanks!'
|
||||
}
|
||||
}
|
||||
|
||||
function constructMessage(
|
||||
transactionHash: string,
|
||||
message?: { text?: string }
|
||||
): string | undefined {
|
||||
return transactionHash
|
||||
? message?.text +
|
||||
'<br /><br />' +
|
||||
getTransactionMessage(transactionHash).transaction
|
||||
: message && message.text
|
||||
}
|
||||
|
||||
const classes = (status: string) =>
|
||||
status === 'success'
|
||||
? styles.success
|
||||
: status === 'error'
|
||||
? styles.error
|
||||
: styles.alert
|
||||
|
||||
export default function Alert({
|
||||
transactionHash,
|
||||
message
|
||||
}: {
|
||||
transactionHash?: string
|
||||
message: { text?: string; status?: string }
|
||||
}): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={classes(message.status || '')}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `${constructMessage(transactionHash as string, message)}`
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
.conversion {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--text-color-light);
|
||||
text-align: left;
|
||||
margin-top: 0;
|
||||
margin-left: calc(var(--spacer) * 2.4);
|
||||
animation: fadeIn 0.5s 0.8s ease-out backwards;
|
||||
}
|
||||
|
||||
.conversion span {
|
||||
margin-left: calc(var(--spacer) / 2);
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, it } from 'vitest'
|
||||
import Conversion from './Conversion'
|
||||
|
||||
describe('Conversion', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(<Conversion amount="1" symbol="ETH" />)
|
||||
})
|
||||
})
|
@ -1,59 +0,0 @@
|
||||
import { type ReactElement, useEffect, useState } from 'react'
|
||||
import styles from './Conversion.module.css'
|
||||
|
||||
export async function getFiat({
|
||||
amount,
|
||||
tokenId = 'ethereum'
|
||||
}: {
|
||||
amount: number
|
||||
tokenId?: string
|
||||
}): Promise<{ [key: string]: string }> {
|
||||
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${tokenId}&vs_currencies=eur%2Cusd`
|
||||
const response = await fetch(url)
|
||||
const json = await response.json()
|
||||
|
||||
if (!json) console.error(response.statusText)
|
||||
const { usd, eur } = json[tokenId]
|
||||
const dollar = (amount * usd).toFixed(2)
|
||||
const euro = (amount * eur).toFixed(2)
|
||||
|
||||
return { dollar, euro }
|
||||
}
|
||||
|
||||
export default function Conversion({
|
||||
amount,
|
||||
symbol
|
||||
}: {
|
||||
amount: string
|
||||
symbol: string
|
||||
}): ReactElement {
|
||||
const [conversion, setConversion] = useState({
|
||||
euro: '0.00',
|
||||
dollar: '0.00'
|
||||
})
|
||||
const { dollar, euro } = conversion
|
||||
|
||||
useEffect(() => {
|
||||
async function getFiatResponse() {
|
||||
try {
|
||||
const tokenId = symbol === 'MATIC' ? 'matic-network' : 'ethereum'
|
||||
const { dollar, euro } = await getFiat({
|
||||
amount: Number(amount),
|
||||
tokenId
|
||||
})
|
||||
setConversion({ euro, dollar })
|
||||
} catch (error) {
|
||||
console.error((error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
getFiatResponse()
|
||||
}, [amount, symbol])
|
||||
|
||||
return (
|
||||
<div className={styles.conversion}>
|
||||
<span>{dollar !== '0.00' && `= $ ${dollar}`}</span>
|
||||
<span>{euro !== '0.00' && `= € ${euro}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
.inputGroup {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
animation: fadeIn 0.8s ease-out backwards;
|
||||
margin-top: var(--spacer);
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.inputGroup button {
|
||||
width: 100%;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.inputGroup button {
|
||||
width: 40%;
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.input {
|
||||
width: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
.inputInput {
|
||||
text-align: center;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: var(--font-size-large);
|
||||
padding: calc(var(--spacer) / 3) calc(var(--spacer) / 3)
|
||||
calc(var(--spacer) / 3) calc(var(--spacer) * 1.7);
|
||||
border-bottom: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.inputInput {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inputInput::-webkit-inner-spin-button {
|
||||
margin-left: -1rem;
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .inputInput {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.currency {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
bottom: 1px;
|
||||
left: 1px;
|
||||
font-size: var(--font-size-small);
|
||||
padding: calc(var(--spacer) / 3);
|
||||
background: var(--box-background-color);
|
||||
border-right: 1px solid var(--text-color-dimmed);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .currency {
|
||||
border-right-color: #000;
|
||||
}
|
||||
|
||||
.message {
|
||||
composes: message from './index.module.css';
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0.01;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
@ -1,41 +0,0 @@
|
||||
import { type ReactElement } from 'react'
|
||||
import Input from '@components/Input'
|
||||
import Conversion from './Conversion'
|
||||
import styles from './InputGroup.module.css'
|
||||
|
||||
export default function InputGroup({
|
||||
amount,
|
||||
isDisabled,
|
||||
symbol,
|
||||
setAmount
|
||||
}: {
|
||||
amount: string
|
||||
isDisabled: boolean
|
||||
symbol: string
|
||||
setAmount(amount: string): void
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.inputGroup}>
|
||||
<div className={styles.input}>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9.]*"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className={styles.inputInput}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<div className={styles.currency}>
|
||||
<span>{symbol}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={isDisabled}>
|
||||
Make it rain
|
||||
</button>
|
||||
</div>
|
||||
<Conversion amount={amount} symbol={symbol} />
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
.web3 {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 25rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.web3 > div:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-small);
|
||||
margin-bottom: var(--spacer);
|
||||
}
|
||||
|
||||
/* connect button */
|
||||
.web3 > div:first-child > button:only-child {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-small);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message::after {
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
animation: ellipsis steps(4, end) 1s infinite;
|
||||
|
||||
/* ascii code for the ellipsis character */
|
||||
content: '\2026';
|
||||
width: 0;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.success {
|
||||
composes: message;
|
||||
color: green;
|
||||
}
|
||||
|
||||
.success::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@keyframes ellipsis {
|
||||
to {
|
||||
width: 0.75rem;
|
||||
}
|
||||
}
|
@ -1,91 +0,0 @@
|
||||
import { type ReactElement, useState } from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { parseEther } from 'viem'
|
||||
import {
|
||||
useAccount,
|
||||
useNetwork,
|
||||
usePrepareSendTransaction,
|
||||
useSendTransaction
|
||||
} from 'wagmi'
|
||||
import { ConnectButton } from '@rainbow-me/rainbowkit'
|
||||
|
||||
import Alert, { getTransactionMessage } from './Alert'
|
||||
import InputGroup from './InputGroup'
|
||||
import styles from './index.module.css'
|
||||
|
||||
export default function Web3Donation({
|
||||
address
|
||||
}: {
|
||||
address: string
|
||||
}): ReactElement {
|
||||
const { address: account } = useAccount()
|
||||
const { chain } = useNetwork()
|
||||
|
||||
const [amount, setAmount] = useState('0.005')
|
||||
const [debouncedAmount] = useDebounce(amount, 500)
|
||||
|
||||
const { config } = usePrepareSendTransaction({
|
||||
to: address,
|
||||
value: debouncedAmount ? parseEther(debouncedAmount) : undefined
|
||||
})
|
||||
const { sendTransactionAsync, isError, isSuccess } =
|
||||
useSendTransaction(config)
|
||||
|
||||
const [message, setMessage] = useState<{ status: string; text: string }>()
|
||||
const [transactionHash, setTransactionHash] = useState<string>()
|
||||
|
||||
async function handleSendTransaction() {
|
||||
setMessage({
|
||||
status: 'loading',
|
||||
text: getTransactionMessage().waitingForUser
|
||||
})
|
||||
|
||||
try {
|
||||
const result = sendTransactionAsync && (await sendTransactionAsync())
|
||||
|
||||
if (isError) {
|
||||
throw new Error(undefined)
|
||||
}
|
||||
|
||||
setTransactionHash(result?.hash)
|
||||
setMessage({
|
||||
status: 'loading',
|
||||
text: getTransactionMessage().waitingConfirmation
|
||||
})
|
||||
|
||||
if (isSuccess) {
|
||||
setMessage({
|
||||
status: 'success',
|
||||
text: getTransactionMessage().success
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = !account
|
||||
|
||||
return (
|
||||
<form
|
||||
className={styles.web3}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSendTransaction()
|
||||
}}
|
||||
>
|
||||
<ConnectButton chainStatus="icon" showBalance={false} />
|
||||
|
||||
{message ? (
|
||||
<Alert message={message} transactionHash={transactionHash} />
|
||||
) : (
|
||||
<InputGroup
|
||||
amount={amount}
|
||||
symbol={chain?.nativeCurrency?.symbol || 'ETH'}
|
||||
setAmount={setAmount}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
@ -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>
|
||||
@ -18,12 +18,10 @@ const { name, url, github } = config.author
|
||||
{name}
|
||||
</a>
|
||||
<a href={`${github}/blog`}>
|
||||
<Github />
|
||||
View source
|
||||
<Github />View source
|
||||
</a>
|
||||
<a href="/thanks/">
|
||||
<Bitcoin />
|
||||
Say Thanks
|
||||
<Bitcoin />Say Thanks
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
@ -10,7 +10,6 @@ const { props } = Astro
|
||||
position: relative;
|
||||
transform: rotate(0deg);
|
||||
cursor: pointer;
|
||||
margin-top: calc(var(--spacer) / 2);
|
||||
}
|
||||
|
||||
.line {
|
||||
@ -57,11 +56,7 @@ const { props } = Astro
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: calc(var(--spacer) / 2);
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
margin-right: -1rem;
|
||||
margin-bottom: -0.4rem;
|
||||
}
|
||||
|
||||
.button:hover,
|
||||
|
@ -1,12 +1,12 @@
|
||||
---
|
||||
import Menu from '@components/Menu/index.astro'
|
||||
import Search from '@components/Search/index.astro'
|
||||
import Search from '@features/Search/index.astro'
|
||||
import ThemeSwitch from '@components/ThemeSwitch/index.astro'
|
||||
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
|
||||
|
@ -6,6 +6,7 @@
|
||||
top: 0;
|
||||
border: 0;
|
||||
will-change: transform;
|
||||
padding-top: calc(var(--spacer) / 3);
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,5 +57,23 @@
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: inline-block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav > div,
|
||||
.nav > button {
|
||||
padding: calc(var(--spacer) / 2);
|
||||
}
|
||||
|
||||
.nav > div,
|
||||
.nav > button,
|
||||
.nav > div > label,
|
||||
.nav > div > label > div {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav > button:last-of-type {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--padding-base-vertical) var(--padding-base-horizontal);
|
||||
font-size: var(--input-font-size);
|
||||
font-weight: var(--input-font-weight);
|
||||
@ -25,7 +24,8 @@
|
||||
/* stylelint-enable selector-no-vendor-prefix */
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--input-border-focus);
|
||||
/* box-shadow: 0 0 0 var(--border-width) var(--input-border-focus); */
|
||||
border-color: var(--input-border-focus) !important;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
|
16
src/components/Loader/Loader.module.css
Normal file
16
src/components/Loader/Loader.module.css
Normal 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);
|
||||
}
|
||||
}
|
8
src/components/Loader/Loader.tsx
Normal file
8
src/components/Loader/Loader.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import styles from './Loader.module.css'
|
||||
import { Icon as LoaderIcon } from '@images/components/react/Loader'
|
||||
|
||||
export function Loader() {
|
||||
// TODO: fix React props for generated SVG components for class/className
|
||||
//@ts-expect-error-next-line
|
||||
return <LoaderIcon className={styles.loader} />
|
||||
}
|
1
src/components/Loader/index.tsx
Normal file
1
src/components/Loader/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './Loader'
|
@ -1,8 +1,5 @@
|
||||
.themeSwitch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
margin-right: calc(var(--spacer) / 4);
|
||||
}
|
||||
|
||||
.themeSwitch svg {
|
||||
@ -19,7 +16,6 @@
|
||||
.checkbox {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
padding: calc(var(--spacer) / 2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
const htmlEl = document.documentElement
|
||||
const themeToggle = document.querySelector('#toggle')
|
||||
const currentTheme = localStorage.getItem('theme')
|
||||
const currentTheme = localStorage.getItem('@kremalicious/theme')
|
||||
|
||||
function getPreferTheme() {
|
||||
if (currentTheme) return currentTheme
|
||||
@ -18,7 +18,7 @@ let themeValue = getPreferTheme()
|
||||
let themeColor = getThemeColor(themeValue)
|
||||
|
||||
function setPreference() {
|
||||
localStorage.setItem('theme', themeValue)
|
||||
localStorage.setItem('@kremalicious/theme', themeValue)
|
||||
reflectPreference()
|
||||
}
|
||||
|
||||
|
@ -21,14 +21,6 @@ import Search from './Search.tsx'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.searchButton {
|
||||
padding: calc(var(--spacer) / 2);
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
margin-right: calc(var(--spacer) / 4);
|
||||
}
|
||||
|
||||
.searchButton:focus {
|
||||
outline: 0;
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
.conversion {
|
||||
font-size: var(--font-size-mini);
|
||||
color: var(--text-color-light);
|
||||
}
|
||||
|
||||
.conversion span {
|
||||
margin-left: calc(var(--spacer) / 3);
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, it } from 'vitest'
|
||||
import { Conversion } from './Conversion'
|
||||
|
||||
describe('Conversion', () => {
|
||||
it('renders without crashing', () => {
|
||||
render(<Conversion />)
|
||||
})
|
||||
})
|
38
src/features/Web3/components/Conversion/Conversion.tsx
Normal file
38
src/features/Web3/components/Conversion/Conversion.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useEffect, type ReactElement, useState } from 'react'
|
||||
import styles from './Conversion.module.css'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $selectedToken } from '@features/Web3/stores/selectedToken'
|
||||
import { $amount } from '@features/Web3/stores'
|
||||
|
||||
export function Conversion(): ReactElement {
|
||||
const selectedToken = useStore($selectedToken)
|
||||
const amount = useStore($amount)
|
||||
|
||||
const [dollar, setDollar] = useState('0.00')
|
||||
const [euro, setEuro] = useState('0.00')
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedToken?.price || !amount) {
|
||||
setDollar('0.00')
|
||||
setEuro('0.00')
|
||||
return
|
||||
}
|
||||
|
||||
const { eur, usd } = selectedToken.price
|
||||
|
||||
const dollar = usd ? (Number(amount) * usd).toFixed(2) : '0.00'
|
||||
const euro = eur ? (Number(amount) * eur).toFixed(2) : '0.00'
|
||||
setDollar(dollar)
|
||||
setEuro(euro)
|
||||
}, [selectedToken?.price, amount])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.conversion}
|
||||
title="Value in USD & EUR at current spot price for selected token on Coingecko."
|
||||
>
|
||||
<span>{`= $ ${dollar}`}</span>
|
||||
<span>{`= € ${euro}`}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
1
src/features/Web3/components/Conversion/index.tsx
Normal file
1
src/features/Web3/components/Conversion/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './Conversion'
|
24
src/features/Web3/components/Form/Form.module.css
Normal file
24
src/features/Web3/components/Form/Form.module.css
Normal file
@ -0,0 +1,24 @@
|
||||
.web3 {
|
||||
margin-top: calc(var(--spacer) / 2);
|
||||
margin-bottom: calc(var(--spacer) / 4);
|
||||
}
|
||||
|
||||
.form {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
min-height: 165px;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
color: var(--text-color-light);
|
||||
font-size: var(--font-size-small);
|
||||
margin-top: calc(var(--spacer) / 2);
|
||||
margin-bottom: calc(var(--spacer) / 6);
|
||||
}
|
||||
|
||||
.disclaimer code {
|
||||
background: none;
|
||||
color: var(--text-color);
|
||||
padding-left: 2px;
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { test, expect } from 'vitest'
|
||||
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import Web3Donation from '.'
|
||||
import { render, fireEvent, screen } from '@testing-library/react'
|
||||
import { Web3Form } from './Form'
|
||||
|
||||
test('Web3Donation component', async () => {
|
||||
render(<Web3Donation address="0x456" />)
|
||||
render(<Web3Form />)
|
||||
|
||||
const submitButton = screen.getByRole('button')
|
||||
expect(submitButton).toBeInTheDocument()
|
||||
@ -18,10 +18,10 @@ test('Web3Donation component', async () => {
|
||||
expect(input).toHaveValue('1')
|
||||
|
||||
// Simulate form submission
|
||||
fireEvent.click(submitButton)
|
||||
// fireEvent.click(submitButton)
|
||||
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByText(/Waiting for network confirmation/i)
|
||||
expect(alert).toBeInTheDocument()
|
||||
})
|
||||
// await waitFor(() => {
|
||||
// const alert = screen.getByText(/Waiting for network confirmation/i)
|
||||
// expect(alert).toBeInTheDocument()
|
||||
// })
|
||||
})
|
63
src/features/Web3/components/Form/Form.tsx
Normal file
63
src/features/Web3/components/Form/Form.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { type ReactElement, useEffect, useState } from 'react'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { InputGroup } from '../Input'
|
||||
import styles from './Form.module.css'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $selectedToken, $isInitSend, $amount } from '@features/Web3/stores'
|
||||
import siteConfig from '@config/blog.config'
|
||||
import { Send } from '../Send'
|
||||
import { RainbowKit } from '../RainbowKit/RainbowKit'
|
||||
|
||||
export function Web3Form(): ReactElement {
|
||||
const { address: account } = useAccount()
|
||||
const selectedToken = useStore($selectedToken)
|
||||
const isInitSend = useStore($isInitSend)
|
||||
const amount = useStore($amount)
|
||||
|
||||
const isDisabled = !account
|
||||
|
||||
const [error, setError] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!amount || amount === '' || !selectedToken?.balance) {
|
||||
setError(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
if (Number(amount) > Number(selectedToken?.balance)) {
|
||||
setError('Exceeds balance')
|
||||
} else {
|
||||
setError(undefined)
|
||||
}
|
||||
}, [amount, selectedToken?.balance])
|
||||
|
||||
// reset amount whenever token changes
|
||||
useEffect(() => {
|
||||
if (!selectedToken) return
|
||||
$amount.set('')
|
||||
}, [selectedToken])
|
||||
|
||||
return (
|
||||
<div className={styles.web3}>
|
||||
{isInitSend ? (
|
||||
<Send />
|
||||
) : (
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (amount === '' || amount === '0') return
|
||||
$isInitSend.set(true)
|
||||
}}
|
||||
>
|
||||
<RainbowKit />
|
||||
<InputGroup isDisabled={isDisabled} error={error} />
|
||||
<div className={styles.disclaimer}>
|
||||
Sends tokens to my account{' '}
|
||||
<code>{siteConfig.author.ether.ens}</code>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
1
src/features/Web3/components/Form/index.tsx
Normal file
1
src/features/Web3/components/Form/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './Form'
|
126
src/features/Web3/components/Input/InputGroup.module.css
Normal file
126
src/features/Web3/components/Input/InputGroup.module.css
Normal file
@ -0,0 +1,126 @@
|
||||
.inputGroup {
|
||||
--height: 50px;
|
||||
--border: var(--border-width) solid var(--border-color);
|
||||
--tokenWidth: 75px;
|
||||
|
||||
margin: auto;
|
||||
margin-top: calc(var(--spacer) / 3);
|
||||
min-height: var(--height);
|
||||
border-radius: var(--border-radius);
|
||||
border: var(--border);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s ease-out;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@media (min-width: 25rem) {
|
||||
.inputGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global([data-theme='dark']) .inputGroup {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.inputGroup.focus {
|
||||
/* box-shadow: 0 0 0 1px var(--input-border-focus); */
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.inputGroup.error,
|
||||
.inputGroup.focus.error {
|
||||
border-color: red !important;
|
||||
}
|
||||
|
||||
.token {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: var(--tokenWidth);
|
||||
height: calc(var(--height) - var(--border-width));
|
||||
background: var(--box-background-color);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inputInput {
|
||||
text-align: center;
|
||||
border: 0;
|
||||
font-size: var(--font-size-large);
|
||||
padding: 0;
|
||||
padding-left: var(--tokenWidth);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom: var(--border);
|
||||
height: var(--height);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputInput:focus {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 25rem) {
|
||||
.token {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.inputInput {
|
||||
border-radius: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.submit {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: var(--height);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (min-width: 25rem) {
|
||||
.submit {
|
||||
width: fit-content;
|
||||
padding: 0 calc(var(--spacer) / 1.5);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.submit:disabled {
|
||||
background: var(--box-background-color);
|
||||
color: var(--text-color-light);
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.errorOutput {
|
||||
color: red;
|
||||
font-size: var(--font-size-mini);
|
||||
margin-left: var(--tokenWidth);
|
||||
position: absolute;
|
||||
right: calc(var(--spacer) / 3);
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.conversion {
|
||||
text-align: center;
|
||||
margin-top: calc(var(--spacer) / 6);
|
||||
animation: fadeIn 0.5s 0.8s ease-out backwards;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 40rem) {
|
||||
.conversion {
|
||||
text-align: left;
|
||||
margin-top: 0;
|
||||
margin-left: calc(var(--spacer) * 3);
|
||||
}
|
||||
}
|
@ -1,39 +1,29 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import InputGroup from './InputGroup'
|
||||
|
||||
const setAmount = vi.fn()
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { InputGroup } from '.'
|
||||
|
||||
describe('InputGroup', () => {
|
||||
it('renders without crashing', async () => {
|
||||
render(
|
||||
<InputGroup
|
||||
amount="1"
|
||||
setAmount={setAmount}
|
||||
isDisabled={false}
|
||||
symbol="ETH"
|
||||
/>
|
||||
)
|
||||
render(<InputGroup isDisabled={false} error={undefined} />)
|
||||
|
||||
const input = await screen.findByRole('textbox')
|
||||
const button = await screen.findByRole('button')
|
||||
fireEvent.change(input, { target: { value: '3' } })
|
||||
fireEvent.click(button)
|
||||
expect(setAmount).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders disabled', async () => {
|
||||
render(
|
||||
<InputGroup
|
||||
amount="1"
|
||||
setAmount={setAmount}
|
||||
isDisabled={true}
|
||||
symbol="ETH"
|
||||
/>
|
||||
)
|
||||
render(<InputGroup isDisabled={true} error={undefined} />)
|
||||
|
||||
const input = await screen.findByRole('textbox')
|
||||
expect(input).toBeDefined()
|
||||
expect(input.attributes.getNamedItem('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders error', async () => {
|
||||
render(<InputGroup isDisabled={false} error={'Hello Error'} />)
|
||||
|
||||
const errorItem = await screen.findByText('Hello Error')
|
||||
expect(errorItem).toBeDefined()
|
||||
})
|
||||
})
|
68
src/features/Web3/components/Input/InputGroup.tsx
Normal file
68
src/features/Web3/components/Input/InputGroup.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { useState, type ReactElement } from 'react'
|
||||
import Input from '@components/Input'
|
||||
import { Conversion } from '../Conversion'
|
||||
import styles from './InputGroup.module.css'
|
||||
import { TokenSelect } from '../TokenSelect'
|
||||
import { $amount, $isInitSend, $selectedToken } from '@features/Web3/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
export function InputGroup({
|
||||
isDisabled,
|
||||
error
|
||||
}: {
|
||||
isDisabled: boolean
|
||||
error: string | undefined
|
||||
}): ReactElement {
|
||||
const amount = useStore($amount)
|
||||
const selectedToken = useStore($selectedToken)
|
||||
|
||||
const [isFocus, setIsFocus] = useState(false)
|
||||
|
||||
function handleChange(newAmount: string) {
|
||||
$amount.set(newAmount)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.inputGroup} ${isFocus ? styles.focus : ''} ${
|
||||
error ? styles.error : ''
|
||||
}`}
|
||||
>
|
||||
<div className={styles.token}>
|
||||
<TokenSelect />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9.]*"
|
||||
value={amount}
|
||||
placeholder="0.00"
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onFocus={() => setIsFocus(true)}
|
||||
onBlur={() => setIsFocus(false)}
|
||||
className={styles.inputInput}
|
||||
/>
|
||||
<button
|
||||
className={`${styles.submit} btn btn-primary`}
|
||||
disabled={
|
||||
isDisabled ||
|
||||
!amount ||
|
||||
amount === '' ||
|
||||
!selectedToken ||
|
||||
Boolean(error)
|
||||
}
|
||||
onClick={() => $isInitSend.set(true)}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
|
||||
{error ? <span className={styles.errorOutput}>{error}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.conversion}>
|
||||
<Conversion />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
1
src/features/Web3/components/Input/index.tsx
Normal file
1
src/features/Web3/components/Input/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './InputGroup'
|
39
src/features/Web3/components/Preview/Data.module.css
Normal file
39
src/features/Web3/components/Preview/Data.module.css
Normal file
@ -0,0 +1,39 @@
|
||||
.table {
|
||||
width: 100%;
|
||||
display: table;
|
||||
margin-bottom: calc(var(--spacer) / 1.5);
|
||||
}
|
||||
|
||||
table[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: calc(var(--spacer) / 4) calc(var(--spacer) / 3);
|
||||
}
|
||||
|
||||
.to,
|
||||
.from {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-color-light);
|
||||
vertical-align: top;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.amount {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.table :global(.TokenLogo),
|
||||
.table :global(.TokenLogo) img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
92
src/features/Web3/components/Preview/Data.tsx
Normal file
92
src/features/Web3/components/Preview/Data.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { formatEther, formatUnits } from 'viem'
|
||||
import { useAccount, useEnsName, useNetwork } from 'wagmi'
|
||||
import type {
|
||||
SendTransactionArgs,
|
||||
WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import styles from './Data.module.css'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $selectedToken } from '@features/Web3/stores'
|
||||
import { truncateAddress } from '@features/Web3/lib/truncateAddress'
|
||||
|
||||
export function Data({
|
||||
to,
|
||||
ensResolved,
|
||||
txConfig,
|
||||
isDisabled
|
||||
}: {
|
||||
to: `0x${string}` | null | undefined
|
||||
ensResolved: string | null | undefined
|
||||
txConfig: SendTransactionArgs | WriteContractPreparedArgs | undefined
|
||||
isDisabled: boolean
|
||||
}) {
|
||||
const { chain } = useNetwork()
|
||||
const { address: from } = useAccount()
|
||||
const { data: ensFrom } = useEnsName({ address: from, chainId: 1 })
|
||||
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 =
|
||||
selectedToken?.decimals === 18
|
||||
? formatEther(value as bigint)
|
||||
: selectedToken?.decimals
|
||||
? formatUnits(value as bigint, selectedToken.decimals)
|
||||
: '0'
|
||||
|
||||
return (
|
||||
<table className={styles.table} aria-disabled={isDisabled}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={styles.label}>You are</td>
|
||||
<td>
|
||||
{ensFrom ? (
|
||||
<abbr title={`${ensFrom} successfully resolved to ${from}`}>
|
||||
<span className={styles.from}>{ensFrom}</span>
|
||||
</abbr>
|
||||
) : (
|
||||
<code className={styles.from}>
|
||||
{from ? truncateAddress(from) : ''}
|
||||
</code>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td className={styles.label}>sending</td>
|
||||
<td className={styles.amount}>
|
||||
<div className="TokenLogo">
|
||||
<img
|
||||
src={selectedToken?.logo || ''}
|
||||
alt={selectedToken?.name || ''}
|
||||
/>
|
||||
</div>
|
||||
<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 className={styles.label}>to</td>
|
||||
<td>
|
||||
<abbr title={`${ensResolved} successfully resolved to ${to}`}>
|
||||
<span className={styles.to}>{ensResolved}</span>
|
||||
</abbr>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
21
src/features/Web3/components/Preview/Preview.module.css
Normal file
21
src/features/Web3/components/Preview/Preview.module.css
Normal file
@ -0,0 +1,21 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.actions button:first-child {
|
||||
margin-right: var(--spacer);
|
||||
width: 115px;
|
||||
height: 45px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.alert {
|
||||
font-size: var(--font-size-small);
|
||||
display: inline-block;
|
||||
}
|
10
src/features/Web3/components/Preview/Preview.test.tsx
Normal file
10
src/features/Web3/components/Preview/Preview.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Preview } from './Preview'
|
||||
|
||||
describe('Preview component', () => {
|
||||
test('renders without crashing', () => {
|
||||
render(<Preview />)
|
||||
expect(screen.getByText('You are')).toBeInTheDocument()
|
||||
})
|
||||
})
|
69
src/features/Web3/components/Preview/Preview.tsx
Normal file
69
src/features/Web3/components/Preview/Preview.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { Loader } from '@components/Loader'
|
||||
import { usePrepareSend } from '@features/Web3/hooks/usePrepareSend'
|
||||
import { useSend } from '@features/Web3/hooks/useSend'
|
||||
import { $isInitSend } from '@features/Web3/stores'
|
||||
import { useEnsAddress, useEnsName } from 'wagmi'
|
||||
import { Data } from './Data'
|
||||
import siteConfig from '@config/blog.config'
|
||||
import styles from './Preview.module.css'
|
||||
|
||||
export function Preview() {
|
||||
// Always resolve to address from ENS name and vice versa
|
||||
// so nobody has to trust my config values.
|
||||
const { ens } = siteConfig.author.ether
|
||||
const { data: to } = useEnsAddress({ name: ens, chainId: 1 })
|
||||
const { data: ensResolved } = useEnsName({
|
||||
address: to as `0x${string}` | undefined,
|
||||
chainId: 1
|
||||
})
|
||||
|
||||
const {
|
||||
data: txConfig,
|
||||
error: prepareError,
|
||||
isError: isPrepareError
|
||||
} = usePrepareSend({ to })
|
||||
const { handleSend, isLoading, error } = useSend({ txConfig })
|
||||
|
||||
// TODO: Cancel flow if chain changes in preview as this can mess with token selection
|
||||
// useEffect(() => {
|
||||
// if (!chain?.id || $isInitSend.get() === false) return
|
||||
// $isInitSend.set(false)
|
||||
// }, [chain?.id])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Data
|
||||
to={to}
|
||||
ensResolved={ensResolved}
|
||||
txConfig={txConfig}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
{console.log(txConfig)}
|
||||
|
||||
{error || prepareError ? (
|
||||
<div className={styles.alert}>{error || prepareError}</div>
|
||||
) : null}
|
||||
|
||||
<footer className={styles.actions}>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e?.preventDefault()
|
||||
await handleSend()
|
||||
}}
|
||||
className="btn btn-primary"
|
||||
disabled={isLoading || !txConfig || isPrepareError}
|
||||
>
|
||||
{isLoading ? <Loader /> : 'Make it rain'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => $isInitSend.set(false)}
|
||||
className="link"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
1
src/features/Web3/components/Preview/index.tsx
Normal file
1
src/features/Web3/components/Preview/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './Preview'
|
@ -0,0 +1,57 @@
|
||||
.rainbowkit button > div {
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
.rainbowkit > div:first-child {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
/* hide the account icon, and hope nothing else */
|
||||
|
||||
/* .rainbowkit button [aria-hidden] {
|
||||
display: none;
|
||||
} */
|
||||
|
||||
.rainbowkit [aria-label='Chain Selector'],
|
||||
.rainbowkit [data-testid='rk-account-button'] div {
|
||||
font-weight: var(--font-weight-base);
|
||||
}
|
||||
|
||||
.rainbowkit [aria-label='Chain Selector']:hover,
|
||||
.rainbowkit [data-testid='rk-account-button']:hover,
|
||||
.rainbowkit [data-testid='rk-account-button']:hover div,
|
||||
.rainbowkit [aria-label='Chain Selector']:focus,
|
||||
.rainbowkit [data-testid='rk-account-button']:focus,
|
||||
.rainbowkit [data-testid='rk-account-button']:focus div {
|
||||
transform: none !important;
|
||||
color: var(--link-color-hover) !important;
|
||||
}
|
||||
|
||||
/* hide the network icon, and hope nothing else */
|
||||
.rainbowkit [aria-label='Chain Selector'] [role='img'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* connect button */
|
||||
.rainbowkit [data-testid='rk-connect-button'] {
|
||||
margin-right: auto;
|
||||
font-family: var(--font-family-headings) !important;
|
||||
font-weight: var(--font-weight-headings) !important;
|
||||
text-transform: uppercase;
|
||||
color: var(--link-color);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: var(--font-size-small);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rainbowkit [data-testid='rk-connect-button']:hover,
|
||||
.rainbowkit [data-testid='rk-connect-button']:focus {
|
||||
color: var(--link-color-hover);
|
||||
background: none;
|
||||
border: none;
|
||||
transform: none;
|
||||
}
|
10
src/features/Web3/components/RainbowKit/RainbowKit.tsx
Normal file
10
src/features/Web3/components/RainbowKit/RainbowKit.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { ConnectButton } from '@rainbow-me/rainbowkit'
|
||||
import styles from './RainbowKit.module.css'
|
||||
|
||||
export function RainbowKit() {
|
||||
return (
|
||||
<div className={styles.rainbowkit}>
|
||||
<ConnectButton chainStatus="full" showBalance={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
0
src/features/Web3/components/RainbowKit/index.tsx
Normal file
0
src/features/Web3/components/RainbowKit/index.tsx
Normal file
10
src/features/Web3/components/Send/Send.tsx
Normal file
10
src/features/Web3/components/Send/Send.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $txHash } from '@features/Web3/stores'
|
||||
import { Success } from '../Success'
|
||||
import { Preview } from '../Preview'
|
||||
|
||||
export function Send() {
|
||||
const txHash = useStore($txHash)
|
||||
|
||||
return txHash ? <Success /> : <Preview />
|
||||
}
|
1
src/features/Web3/components/Send/index.tsx
Normal file
1
src/features/Web3/components/Send/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './Send'
|
19
src/features/Web3/components/Success/ExplorerLink.tsx
Normal file
19
src/features/Web3/components/Success/ExplorerLink.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { $txHash } from '@features/Web3/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
export function ExplorerLink({
|
||||
url,
|
||||
children
|
||||
}: {
|
||||
url: string | undefined
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const txHash = useStore($txHash)
|
||||
const explorerLink = `${url}/tx/${txHash}`
|
||||
|
||||
return (
|
||||
<a href={explorerLink} target="_blank" rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
23
src/features/Web3/components/Success/Success.module.css
Normal file
23
src/features/Web3/components/Success/Success.module.css
Normal file
@ -0,0 +1,23 @@
|
||||
.success {
|
||||
text-align: center;
|
||||
margin-top: var(--spacer);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-h3);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 115px;
|
||||
height: 45px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.success code {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
10
src/features/Web3/components/Success/Success.test.tsx
Normal file
10
src/features/Web3/components/Success/Success.test.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Success } from './Success'
|
||||
|
||||
describe('Success component', () => {
|
||||
test('renders without crashing', () => {
|
||||
render(<Success />)
|
||||
expect(screen.getByText(`You're amazing, thanks!`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
41
src/features/Web3/components/Success/Success.tsx
Normal file
41
src/features/Web3/components/Success/Success.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { $txHash, $isInitSend } from '@features/Web3/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import styles from './Success.module.css'
|
||||
import { useNetwork } from 'wagmi'
|
||||
import { ExplorerLink } from './ExplorerLink'
|
||||
|
||||
const title = `You're amazing, thanks!`
|
||||
const description = `Your transaction is on its way. You can check the status on`
|
||||
|
||||
export function Success() {
|
||||
const { chain } = useNetwork()
|
||||
const txHash = useStore($txHash)
|
||||
|
||||
const explorerName = chain?.blockExplorers?.default.name
|
||||
const explorerUrl = chain?.blockExplorers?.default.url
|
||||
|
||||
return (
|
||||
<div className={styles.success}>
|
||||
<h5 className={styles.title}>{title}</h5>
|
||||
|
||||
<p>
|
||||
{description}{' '}
|
||||
<ExplorerLink url={explorerUrl}>{explorerName}</ExplorerLink>.
|
||||
</p>
|
||||
<p>
|
||||
<ExplorerLink url={explorerUrl}>
|
||||
<code>{txHash}</code>
|
||||
</ExplorerLink>
|
||||
</p>
|
||||
|
||||
<footer className={styles.actions}>
|
||||
<button
|
||||
onClick={() => $isInitSend.set(false)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
1
src/features/Web3/components/Success/index.tsx
Normal file
1
src/features/Web3/components/Success/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './Success'
|
71
src/features/Web3/components/TokenSelect/Token.css
Normal file
71
src/features/Web3/components/TokenSelect/Token.css
Normal file
@ -0,0 +1,71 @@
|
||||
.Token {
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1;
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: calc(var(--spacer) / 3) calc(var(--spacer) / 2)
|
||||
calc(var(--spacer) / 3) 25px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.Token[data-disabled] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.Token[data-highlighted] {
|
||||
outline: none;
|
||||
background-color: var(--box-background-color);
|
||||
}
|
||||
|
||||
.TokenLogo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
margin-right: calc(var(--spacer) / 4);
|
||||
border: 1px solid var(--border-color);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.TokenLogo img {
|
||||
margin: 0;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.TokenName,
|
||||
.TokenBalance {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.TokenName {
|
||||
font-size: var(--font-size-base);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.TokenBalance {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.TokenValue {
|
||||
font-size: var(--font-size-small);
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
margin-left: auto;
|
||||
padding-left: calc(var(--spacer) * 2);
|
||||
}
|
||||
|
||||
.SelectItemIndicator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 25px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
57
src/features/Web3/components/TokenSelect/Token.tsx
Normal file
57
src/features/Web3/components/TokenSelect/Token.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import { forwardRef, type HTMLAttributes } from 'react'
|
||||
import * as Select from '@radix-ui/react-select'
|
||||
import { formatCurrency } from '@coingecko/cryptoformat'
|
||||
import './Token.css'
|
||||
import { Icon as Check } from '@images/components/react/Check'
|
||||
import type { GetToken } from '@features/Web3/hooks/useFetchTokens'
|
||||
|
||||
interface SelectItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
token: GetToken | undefined
|
||||
}
|
||||
|
||||
export const Token = forwardRef<HTMLDivElement, SelectItemProps>(
|
||||
({ className, token, ...props }, forwardedRef) => {
|
||||
const balance =
|
||||
token?.balance && token?.symbol
|
||||
? formatCurrency(token.balance, token.symbol, 'en', false, {
|
||||
decimalPlaces: 3,
|
||||
significantFigures: 3
|
||||
})
|
||||
: 0
|
||||
|
||||
const valueInUsd =
|
||||
token?.balance && token?.price?.usd
|
||||
? token?.balance * token?.price.usd
|
||||
: 0
|
||||
const valueInUsdFormatted = formatCurrency(valueInUsd, 'USD', 'en')
|
||||
|
||||
return balance && parseInt(balance) !== 0 && valueInUsd >= 1 ? (
|
||||
<Select.Item
|
||||
className={`${className ? className : ''} Token`}
|
||||
{...props}
|
||||
value={token?.address || ''}
|
||||
title={token?.address}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<Select.ItemText>
|
||||
<span className="TokenLogo">
|
||||
{token?.logo ? (
|
||||
<img src={token.logo} width="32" height="32" />
|
||||
) : (
|
||||
token?.symbol?.substring(0, 3)
|
||||
)}
|
||||
</span>
|
||||
</Select.ItemText>
|
||||
<div>
|
||||
<h3 className="TokenName">{token?.name}</h3>
|
||||
<p className="TokenBalance">{balance}</p>
|
||||
</div>
|
||||
<div className="TokenValue">{valueInUsdFormatted}</div>
|
||||
|
||||
<Select.ItemIndicator className="SelectItemIndicator">
|
||||
<Check />
|
||||
</Select.ItemIndicator>
|
||||
</Select.Item>
|
||||
) : null
|
||||
}
|
||||
)
|
61
src/features/Web3/components/TokenSelect/TokenSelect.css
Normal file
61
src/features/Web3/components/TokenSelect/TokenSelect.css
Normal file
@ -0,0 +1,61 @@
|
||||
.SelectTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-size: var(--font-size-small);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.SelectTrigger:hover {
|
||||
background-color: var(--text-color-dimmed);
|
||||
}
|
||||
|
||||
.SelectTrigger:focus {
|
||||
background-color: var(--text-color-dimmed);
|
||||
}
|
||||
|
||||
.SelectTrigger[data-disabled] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.SelectTrigger .TokenLogo {
|
||||
margin-right: calc(var(--spacer) / 12);
|
||||
}
|
||||
|
||||
.SelectContent {
|
||||
overflow: hidden;
|
||||
background-color: var(--body-background-color);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.SelectViewport {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.SelectLabel {
|
||||
padding: calc(var(--spacer) / 4);
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--text-color);
|
||||
text-transform: capitalize;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.SelectSeparator {
|
||||
height: 1px;
|
||||
background-color: var(--border-color);
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.SelectScrollButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 25px;
|
||||
background-color: var(--body-background-color);
|
||||
color: var(--text-color);
|
||||
cursor: default;
|
||||
}
|
83
src/features/Web3/components/TokenSelect/TokenSelect.tsx
Normal file
83
src/features/Web3/components/TokenSelect/TokenSelect.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import * as Select from '@radix-ui/react-select'
|
||||
import './TokenSelect.css'
|
||||
import { Token } from './Token'
|
||||
import { Icon as ChevronDown } from '@images/components/react/ChevronDown'
|
||||
import { Icon as ChevronsDown } from '@images/components/react/ChevronsDown'
|
||||
import { Icon as ChevronsUp } from '@images/components/react/ChevronsUp'
|
||||
import { useFetchTokens } from '@features/Web3/hooks/useFetchTokens'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $selectedToken, $setSelectedToken } from '@features/Web3/stores'
|
||||
import { Loader } from '@components/Loader'
|
||||
import { useAccount } from 'wagmi'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export function TokenSelect() {
|
||||
const { address } = useAccount()
|
||||
const { data: tokens, isLoading } = useFetchTokens()
|
||||
const selectedToken = useStore($selectedToken)
|
||||
|
||||
const items = tokens?.map((token) => (
|
||||
<Token key={token.address} token={token} />
|
||||
))
|
||||
|
||||
function handleValueChange(value: `0x${string}`) {
|
||||
const token = tokens?.find((token) => token.address === value)
|
||||
if (!token) return
|
||||
|
||||
$setSelectedToken(token)
|
||||
}
|
||||
|
||||
// Auto-select native token
|
||||
// when no selection was made yet
|
||||
useEffect(() => {
|
||||
if (selectedToken?.address || !tokens || !tokens?.length) return
|
||||
|
||||
handleValueChange('0x0')
|
||||
}, [tokens, selectedToken])
|
||||
|
||||
return tokens && address ? (
|
||||
<Select.Root
|
||||
defaultValue={selectedToken?.address || tokens[0].address}
|
||||
value={selectedToken?.address}
|
||||
onValueChange={(value: `0x${string}`) => handleValueChange(value)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Select.Trigger
|
||||
className="SelectTrigger"
|
||||
disabled={isLoading}
|
||||
aria-label="Token"
|
||||
>
|
||||
<Select.Value placeholder="..." />
|
||||
<Select.Icon>
|
||||
<ChevronDown />
|
||||
</Select.Icon>
|
||||
</Select.Trigger>
|
||||
|
||||
{/* @ts-expect-error-next-line: style actually is passed through and is needed in our case */}
|
||||
<Select.Portal style={{ zIndex: 10 }}>
|
||||
<Select.Content className="SelectContent">
|
||||
<Select.ScrollUpButton className="SelectScrollButton">
|
||||
<ChevronsUp />
|
||||
</Select.ScrollUpButton>
|
||||
<Select.Viewport className="SelectViewport">
|
||||
<Select.Group>
|
||||
<Select.Label className="SelectLabel">
|
||||
In Your Wallet
|
||||
</Select.Label>
|
||||
{items}
|
||||
</Select.Group>
|
||||
</Select.Viewport>
|
||||
<Select.ScrollDownButton className="SelectScrollButton">
|
||||
<ChevronsDown />
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
</Select.Portal>
|
||||
</Select.Root>
|
||||
) : isLoading ? (
|
||||
<div className="Token">
|
||||
<div className="TokenLogo TokenLoading">
|
||||
<Loader />
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
1
src/features/Web3/components/TokenSelect/index.tsx
Normal file
1
src/features/Web3/components/TokenSelect/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './TokenSelect'
|
14
src/features/Web3/components/Web3.tsx
Normal file
14
src/features/Web3/components/Web3.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { RainbowKitProvider } from '@rainbow-me/rainbowkit'
|
||||
import { WagmiConfig } from 'wagmi'
|
||||
import { wagmiConfig, chains, theme } from '../lib/rainbowkit'
|
||||
import { Web3Form } from './Form'
|
||||
|
||||
export function Web3() {
|
||||
return (
|
||||
<WagmiConfig config={wagmiConfig}>
|
||||
<RainbowKitProvider chains={chains} theme={theme}>
|
||||
<Web3Form />
|
||||
</RainbowKitProvider>
|
||||
</WagmiConfig>
|
||||
)
|
||||
}
|
2
src/features/Web3/hooks/useFetchTokens/index.ts
Normal file
2
src/features/Web3/hooks/useFetchTokens/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './useFetchTokens'
|
||||
export * from './types'
|
13
src/features/Web3/hooks/useFetchTokens/types.ts
Normal file
13
src/features/Web3/hooks/useFetchTokens/types.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export type GetToken = {
|
||||
address: `0x${string}`
|
||||
balance: number | undefined
|
||||
chainId: number
|
||||
name: string | null
|
||||
symbol: string | null
|
||||
decimals: number | null
|
||||
logo: string | null
|
||||
price: {
|
||||
usd: number | null
|
||||
eur: number | null
|
||||
}
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import { test, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { useFetchTokens } from './useFetchTokens'
|
||||
|
||||
test('useFetchTokens does not fetch anything when no chain or address are present', async () => {
|
||||
vi.mock('wagmi', () => ({
|
||||
useNetwork: () => ({ chain: undefined }),
|
||||
useAccount: () => ({ address: undefined })
|
||||
}))
|
||||
|
||||
function TestComponent() {
|
||||
const fetchResults = useFetchTokens()
|
||||
return <div>{fetchResults?.data ? 'Fetched' : 'Not fetched'}</div>
|
||||
}
|
||||
|
||||
render(<TestComponent />)
|
||||
|
||||
expect(screen.queryByText('Fetched')).toBeNull()
|
||||
})
|
33
src/features/Web3/hooks/useFetchTokens/useFetchTokens.tsx
Normal file
33
src/features/Web3/hooks/useFetchTokens/useFetchTokens.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useNetwork, useAccount } from 'wagmi'
|
||||
import type { GetToken } from './types'
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
const apiUrl = import.meta.env.PUBLIC_WEB3_API_URL
|
||||
|
||||
//
|
||||
// Wrapper for fetching user tokens with swr.
|
||||
//
|
||||
export function useFetchTokens() {
|
||||
const { chain } = useNetwork()
|
||||
const { address } = useAccount()
|
||||
|
||||
const [url, setUrl] = useState<string | undefined>()
|
||||
|
||||
const fetchResults = useSWR<GetToken[] | undefined>(url, fetcher)
|
||||
|
||||
// Set url only after we have all data loaded on client,
|
||||
// preventing initial fetch.
|
||||
useEffect(() => {
|
||||
if (!address || !chain?.id) {
|
||||
setUrl(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${apiUrl}/balance?address=${address}&chainId=${chain?.id}`
|
||||
setUrl(url)
|
||||
}, [address, chain?.id])
|
||||
|
||||
return fetchResults
|
||||
}
|
13
src/features/Web3/hooks/usePrepareSend/abiErc20Transfer.ts
Normal file
13
src/features/Web3/hooks/usePrepareSend/abiErc20Transfer.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export const abiErc20Transfer = [
|
||||
{
|
||||
constant: false,
|
||||
inputs: [
|
||||
{ name: '_to', type: 'address' },
|
||||
{ name: '_value', type: 'uint256' }
|
||||
],
|
||||
name: 'transfer',
|
||||
outputs: [{ name: 'success', type: 'bool' }],
|
||||
stateMutability: 'nonpayable',
|
||||
type: 'function'
|
||||
}
|
||||
]
|
1
src/features/Web3/hooks/usePrepareSend/index.tsx
Normal file
1
src/features/Web3/hooks/usePrepareSend/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './usePrepareSend'
|
43
src/features/Web3/hooks/usePrepareSend/prepare.test.ts
Normal file
43
src/features/Web3/hooks/usePrepareSend/prepare.test.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { test, expect, vi } from 'vitest'
|
||||
import { prepare } from './prepare'
|
||||
import * as wagmiActionsMock from '../../../../../test/__mocks__/wagmi/actions'
|
||||
|
||||
test('prepare with undefined params', async () => {
|
||||
try {
|
||||
await prepare(undefined, undefined, undefined, undefined)
|
||||
expect(true).toBe(false)
|
||||
} catch (e) {
|
||||
expect(true).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('prepare with isNative true uses correct method', async () => {
|
||||
const selectedToken = {
|
||||
address: '0x0',
|
||||
decimals: 18
|
||||
// Add other required properties here
|
||||
}
|
||||
|
||||
const amount = '1'
|
||||
const to = '0xabcdef1234567890'
|
||||
const chainId = 1
|
||||
|
||||
const spy = vi.spyOn(wagmiActionsMock, 'prepareSendTransaction')
|
||||
await prepare(selectedToken as any, amount, to, chainId)
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('prepare with isNative false uses correct method', async () => {
|
||||
const selectedToken = {
|
||||
address: '0xabcdef1234567890',
|
||||
decimals: 18
|
||||
}
|
||||
|
||||
const amount = '1'
|
||||
const to = '0xabcdef1234567890'
|
||||
const chainId = 1
|
||||
|
||||
const spy = vi.spyOn(wagmiActionsMock, 'prepareWriteContract')
|
||||
await prepare(selectedToken as any, amount, to, chainId)
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
})
|
42
src/features/Web3/hooks/usePrepareSend/prepare.ts
Normal file
42
src/features/Web3/hooks/usePrepareSend/prepare.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { parseEther, parseUnits } from 'viem'
|
||||
import {
|
||||
prepareSendTransaction,
|
||||
prepareWriteContract,
|
||||
type SendTransactionArgs,
|
||||
type WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import { abiErc20Transfer } from './abiErc20Transfer'
|
||||
import type { GetToken } from '../useFetchTokens'
|
||||
|
||||
export async function prepare(
|
||||
selectedToken: GetToken | undefined,
|
||||
amount: string | undefined,
|
||||
to: `0x${string}` | null | undefined,
|
||||
chainId: number | undefined
|
||||
) {
|
||||
if (
|
||||
!chainId ||
|
||||
!to ||
|
||||
!amount ||
|
||||
!selectedToken ||
|
||||
!selectedToken?.address ||
|
||||
!selectedToken?.decimals
|
||||
)
|
||||
return
|
||||
|
||||
const isNative = selectedToken.address === '0x0'
|
||||
const requestNative = { chainId, to, value: parseEther(amount) }
|
||||
const requestErc20 = {
|
||||
chainId,
|
||||
address: selectedToken.address,
|
||||
abi: abiErc20Transfer,
|
||||
functionName: 'transfer',
|
||||
args: [to, parseUnits(amount, selectedToken.decimals)]
|
||||
}
|
||||
|
||||
const config = isNative
|
||||
? ((await prepareSendTransaction(requestNative)) as SendTransactionArgs)
|
||||
: ((await prepareWriteContract(requestErc20)) as WriteContractPreparedArgs)
|
||||
|
||||
return config
|
||||
}
|
59
src/features/Web3/hooks/usePrepareSend/usePrepareSend.tsx
Normal file
59
src/features/Web3/hooks/usePrepareSend/usePrepareSend.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useNetwork } from 'wagmi'
|
||||
import type {
|
||||
SendTransactionArgs,
|
||||
WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import { $amount, $selectedToken } from '@features/Web3/stores'
|
||||
import { prepare } from './prepare'
|
||||
|
||||
export function usePrepareSend({
|
||||
to
|
||||
}: {
|
||||
to: `0x${string}` | null | undefined
|
||||
}) {
|
||||
const selectedToken = useStore($selectedToken)
|
||||
const amount = useStore($amount)
|
||||
const { chain } = useNetwork()
|
||||
|
||||
const [txConfig, setTxConfig] = useState<
|
||||
SendTransactionArgs | WriteContractPreparedArgs
|
||||
>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isError, setIsError] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
if (!selectedToken || !amount || !to || !chain?.id) return
|
||||
|
||||
setError(undefined)
|
||||
setIsError(false)
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const config = await prepare(selectedToken, amount, to, chain.id)
|
||||
setTxConfig(config)
|
||||
} catch (error: unknown) {
|
||||
console.error((error as Error).message)
|
||||
|
||||
setIsError(true)
|
||||
|
||||
// only expose useful errors in UI
|
||||
if (
|
||||
(error as Error).message.includes(
|
||||
'this transaction exceeds the balance of the account.'
|
||||
)
|
||||
) {
|
||||
setError(undefined)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
init()
|
||||
}, [selectedToken || amount || to || chain?.id])
|
||||
|
||||
return { data: txConfig, isLoading, isError, error }
|
||||
}
|
1
src/features/Web3/hooks/useSend/index.tsx
Normal file
1
src/features/Web3/hooks/useSend/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './useSend'
|
@ -0,0 +1,12 @@
|
||||
import { test, expect } from 'vitest'
|
||||
import { isUnhelpfulErrorMessage } from './isUnhelpfulErrorMessage'
|
||||
|
||||
test('returns true for unhelpful error messages', () => {
|
||||
const unhelpfulMessage = 'User rejected the request'
|
||||
expect(isUnhelpfulErrorMessage(unhelpfulMessage)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false for helpful error messages', () => {
|
||||
const helpfulMessage = 'Error: Transaction has been reverted by the EVM'
|
||||
expect(isUnhelpfulErrorMessage(helpfulMessage)).toBe(false)
|
||||
})
|
11
src/features/Web3/hooks/useSend/isUnhelpfulErrorMessage.ts
Normal file
11
src/features/Web3/hooks/useSend/isUnhelpfulErrorMessage.ts
Normal file
@ -0,0 +1,11 @@
|
||||
const terribleErrorMessages = [
|
||||
'User rejected the request',
|
||||
'User denied transaction signature',
|
||||
'Cannot read properties of undefined'
|
||||
]
|
||||
|
||||
export function isUnhelpfulErrorMessage(message: string) {
|
||||
return terribleErrorMessages.some((terribleMessage) =>
|
||||
message.includes(terribleMessage)
|
||||
)
|
||||
}
|
30
src/features/Web3/hooks/useSend/send.test.ts
Normal file
30
src/features/Web3/hooks/useSend/send.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { test, expect, vi } from 'vitest'
|
||||
import { send } from './send'
|
||||
import * as wagmiActionsMock from '../../../../../test/__mocks__/wagmi/actions'
|
||||
|
||||
test('with undefined params', async () => {
|
||||
const result = await send(undefined, undefined)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
test('with isNative true uses correct method', async () => {
|
||||
const selectedToken = {
|
||||
address: '0x0',
|
||||
decimals: 18
|
||||
}
|
||||
|
||||
const spy = vi.spyOn(wagmiActionsMock, 'sendTransaction')
|
||||
await send(selectedToken as any, {} as any)
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('with isNative false uses correct method', async () => {
|
||||
const selectedToken = {
|
||||
address: '0xabcdef1234567890',
|
||||
decimals: 18
|
||||
}
|
||||
|
||||
const spy = vi.spyOn(wagmiActionsMock, 'writeContract')
|
||||
await send(selectedToken as any, {} as any)
|
||||
expect(spy).toHaveBeenCalledOnce()
|
||||
})
|
21
src/features/Web3/hooks/useSend/send.ts
Normal file
21
src/features/Web3/hooks/useSend/send.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import {
|
||||
sendTransaction as sendNative,
|
||||
writeContract,
|
||||
type SendTransactionArgs,
|
||||
type WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import type { GetToken } from '../useFetchTokens'
|
||||
|
||||
export async function send(
|
||||
selectedToken: GetToken | undefined,
|
||||
config: SendTransactionArgs | WriteContractPreparedArgs | undefined
|
||||
) {
|
||||
if (!config || !selectedToken) return
|
||||
|
||||
const result =
|
||||
selectedToken?.address === '0x0'
|
||||
? await sendNative(config as SendTransactionArgs)
|
||||
: await writeContract(config as WriteContractPreparedArgs)
|
||||
|
||||
return result
|
||||
}
|
46
src/features/Web3/hooks/useSend/useSend.tsx
Normal file
46
src/features/Web3/hooks/useSend/useSend.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { $txHash, $selectedToken } from '@features/Web3/stores'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import type {
|
||||
SendTransactionArgs,
|
||||
WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import { send } from './send'
|
||||
import { isUnhelpfulErrorMessage } from './isUnhelpfulErrorMessage'
|
||||
|
||||
export function useSend({
|
||||
txConfig
|
||||
}: {
|
||||
txConfig: SendTransactionArgs | WriteContractPreparedArgs | undefined
|
||||
}) {
|
||||
const selectedToken = useStore($selectedToken)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isError, setIsError] = useState(false)
|
||||
const [error, setError] = useState<string | undefined>()
|
||||
|
||||
async function handleSend() {
|
||||
try {
|
||||
setIsError(false)
|
||||
setError(undefined)
|
||||
setIsLoading(true)
|
||||
const result = await send(selectedToken, txConfig)
|
||||
$txHash.set(result?.hash)
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = (error as Error).message
|
||||
console.error(errorMessage)
|
||||
|
||||
// only expose useful errors in UI
|
||||
if (isUnhelpfulErrorMessage(errorMessage)) {
|
||||
setError(undefined)
|
||||
} else {
|
||||
setError((error as Error).message)
|
||||
}
|
||||
setIsError(true)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { handleSend, isLoading, error, isError }
|
||||
}
|
1
src/features/Web3/index.tsx
Normal file
1
src/features/Web3/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './components/Web3'
|
@ -1,6 +1,6 @@
|
||||
import { type Theme, getDefaultWallets } from '@rainbow-me/rainbowkit'
|
||||
import { configureChains, createConfig } from 'wagmi'
|
||||
import { mainnet, polygon } from 'wagmi/chains'
|
||||
import { mainnet, polygon, base, optimism } from 'wagmi/chains'
|
||||
import { infuraProvider } from 'wagmi/providers/infura'
|
||||
import { publicProvider } from 'wagmi/providers/public'
|
||||
|
||||
@ -13,7 +13,7 @@ if (isProduction && (!PUBLIC_INFURA_ID || !PUBLIC_WALLETCONNECT_ID)) {
|
||||
}
|
||||
|
||||
export const { chains, publicClient } = configureChains(
|
||||
[mainnet, polygon],
|
||||
[mainnet, polygon, base, optimism],
|
||||
[infuraProvider({ apiKey: PUBLIC_INFURA_ID }), publicProvider()]
|
||||
)
|
||||
|
||||
@ -29,16 +29,16 @@ export const wagmiConfig = createConfig({
|
||||
publicClient
|
||||
})
|
||||
|
||||
export const theme = {
|
||||
export const theme: Theme = {
|
||||
colors: {
|
||||
accentColor: 'var(--brand-cyan)',
|
||||
accentColorForeground: '#161a1b',
|
||||
actionButtonBorder: 'var(--body-background-color)',
|
||||
actionButtonBorderMobile: 'var(--body-background-color)',
|
||||
actionButtonBorder: 'var(--border-color)',
|
||||
actionButtonBorderMobile: 'var(--border-color)',
|
||||
actionButtonSecondaryBackground: 'var(--box-background-color)',
|
||||
closeButton: 'var(--text-color)',
|
||||
closeButtonBackground: 'var(--box-background-color)',
|
||||
connectButtonBackground: 'var(--body-background-color)',
|
||||
connectButtonBackground: 'transparent',
|
||||
connectButtonBackgroundError: 'var(--alert-error)',
|
||||
connectButtonInnerBackground: 'var(--box-background-color)',
|
||||
connectButtonText: 'var(--text-color)',
|
||||
@ -47,10 +47,10 @@ export const theme = {
|
||||
error: 'var(--alert-error)',
|
||||
generalBorder: 'var(--border-color)',
|
||||
generalBorderDim: 'var(--border-color)',
|
||||
menuItemBackground: 'var(--link-color)',
|
||||
menuItemBackground: 'var(--border-color)',
|
||||
modalBackdrop: 'rgba(0, 0, 0, 0.5)',
|
||||
modalBackground: 'var(--body-background-color)',
|
||||
modalBorder: 'var(--body-background-color)',
|
||||
modalBorder: 'var(--border-color)',
|
||||
modalText: 'var(--text-color)',
|
||||
modalTextDim: 'var(--text-color-dimmed)',
|
||||
modalTextSecondary: 'var(--text-color-light)',
|
||||
@ -58,7 +58,9 @@ export const theme = {
|
||||
profileActionHover: 'var(--box-background-color)',
|
||||
profileForeground: 'var(--body-background-color)',
|
||||
selectedOptionBorder: 'var(--border-color)',
|
||||
standby: 'var(--text-color-dimmed)'
|
||||
standby: 'var(--text-color-light)',
|
||||
downloadBottomCardBackground: 'var(--body-background-color)',
|
||||
downloadTopCardBackground: 'var(--body-background-color)'
|
||||
},
|
||||
fonts: {
|
||||
body: 'var(--font-family-base)'
|
||||
@ -71,11 +73,14 @@ export const theme = {
|
||||
modalMobile: 'var(--border-radius)'
|
||||
},
|
||||
shadows: {
|
||||
connectButton: 'var(--box-shadow)',
|
||||
connectButton: 'none',
|
||||
dialog: 'var(--box-shadow)',
|
||||
profileDetailsAction: 'none',
|
||||
selectedOption: 'var(--box-shadow)',
|
||||
selectedWallet: 'var(--box-shadow)',
|
||||
selectedOption: 'none',
|
||||
selectedWallet: 'none',
|
||||
walletLogo: 'var(--box-shadow)'
|
||||
},
|
||||
blurs: {
|
||||
modalOverlay: 'initial'
|
||||
}
|
||||
}
|
||||
} as Theme
|
1
src/features/Web3/lib/truncateAddress/index.ts
Normal file
1
src/features/Web3/lib/truncateAddress/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './truncateAddress'
|
@ -0,0 +1,11 @@
|
||||
import { test, expect } from 'vitest'
|
||||
import { truncateAddress } from './truncateAddress'
|
||||
|
||||
test('truncateAddress', () => {
|
||||
const address = '0x1234567890abcdef'
|
||||
const truncated = truncateAddress(address)
|
||||
|
||||
expect(truncated.startsWith('0x1234')).toBe(true)
|
||||
expect(truncated.endsWith('cdef')).toBe(true)
|
||||
expect(truncated).toBe('0x1234...cdef')
|
||||
})
|
9
src/features/Web3/lib/truncateAddress/truncateAddress.ts
Normal file
9
src/features/Web3/lib/truncateAddress/truncateAddress.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export function truncateAddress(
|
||||
address: `0x${string}`,
|
||||
startLength = 6,
|
||||
endLength = 4
|
||||
) {
|
||||
return `${address.substring(0, startLength)}...${address.substring(
|
||||
address.length - endLength
|
||||
)}`
|
||||
}
|
2
src/features/Web3/stores/index.ts
Normal file
2
src/features/Web3/stores/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './selectedToken'
|
||||
export * from './send'
|
23
src/features/Web3/stores/selectedToken.ts
Normal file
23
src/features/Web3/stores/selectedToken.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { action } from 'nanostores'
|
||||
import { persistentAtom } from '@nanostores/persistent'
|
||||
import type { GetToken } from '../hooks/useFetchTokens'
|
||||
|
||||
// export const $selectedToken = atom<GetToken | undefined>()
|
||||
|
||||
export const $selectedToken = persistentAtom<GetToken | undefined>(
|
||||
'@kremalicious/selectedToken',
|
||||
undefined,
|
||||
{
|
||||
encode: JSON.stringify,
|
||||
decode: JSON.parse
|
||||
}
|
||||
)
|
||||
|
||||
export const $setSelectedToken = action(
|
||||
$selectedToken,
|
||||
'setSelectedToken',
|
||||
(store, token: GetToken | undefined) => {
|
||||
store.set(token)
|
||||
return store.get()
|
||||
}
|
||||
)
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user