1
0
mirror of https://github.com/kremalicious/blog.git synced 2024-11-22 01:46:51 +01:00

Merge pull request #855 from kremalicious/sponsor

new thanks page
This commit is contained in:
Matthias Kretschmann 2023-11-05 22:43:39 +00:00 committed by GitHub
commit 5a431bce58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
133 changed files with 2862 additions and 800 deletions

View File

@ -9,7 +9,10 @@ export default {
mastodon: 'https://mas.to/@krema', mastodon: 'https://mas.to/@krema',
github: 'https://github.com/kremalicious', github: 'https://github.com/kremalicious',
bitcoin: '171qDmKEXm9YBgBLXyGjjPvopP5o9htQ1V', bitcoin: '171qDmKEXm9YBgBLXyGjjPvopP5o9htQ1V',
ether: '0xf50F267b5689b005FE107cfdb34619f24c014457' ether: {
ens: 'krema.eth',
address: '0xf50F267b5689b005FE107cfdb34619f24c014457'
}
}, },
rss: '/feed.xml', rss: '/feed.xml',
jsonfeed: '/feed.json', jsonfeed: '/feed.json',

View File

@ -1,4 +1,4 @@
#!/usr/bin/env sh #!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh" . "$(dirname -- "$0")/_/husky.sh"
npm run validate npx lint-staged

View File

@ -4,4 +4,5 @@ PUBLIC_TYPEKIT_ID=xxx
PUBLIC_UMAMI_SCRIPT_URL=xxx PUBLIC_UMAMI_SCRIPT_URL=xxx
PUBLIC_UMAMI_WEBSITE_ID=xxx PUBLIC_UMAMI_WEBSITE_ID=xxx
PUBLIC_INFURA_ID=xxx PUBLIC_INFURA_ID=xxx
PUBLIC_WALLETCONNECT_ID="xxx" PUBLIC_WALLETCONNECT_ID="xxx"
PUBLIC_WEB3_API_URL="xxx"

3
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,3 @@
github: kremalicious
patreon: kremalicious
custom: ['https://kremalicious.com/thanks']

View File

@ -16,6 +16,7 @@ env:
PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.GATSBY_UMAMI_WEBSITE_ID }} PUBLIC_UMAMI_WEBSITE_ID: ${{ secrets.GATSBY_UMAMI_WEBSITE_ID }}
PUBLIC_INFURA_ID: ${{ secrets.GATSBY_INFURA_ID }} PUBLIC_INFURA_ID: ${{ secrets.GATSBY_INFURA_ID }}
PUBLIC_WALLETCONNECT_ID: ${{ secrets.GATSBY_WALLETCONNECT_ID }} PUBLIC_WALLETCONNECT_ID: ${{ secrets.GATSBY_WALLETCONNECT_ID }}
PUBLIC_WEB3_API_URL: ${{ secrets.PUBLIC_WEB3_API_URL }}
jobs: jobs:
lint: lint:

View File

@ -18,10 +18,10 @@
- [🎉 Features](#-features) - [🎉 Features](#-features)
- [🌅 Image handling](#-image-handling) - [🌅 Image handling](#-image-handling)
- [🎆 EXIF extraction](#-exif-extraction) - [🎆 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) - [🔍 Search](#-search)
- [🕸 Related Posts](#-related-posts) - [🕸 Related Posts](#-related-posts)
- [📝 GitHub changelog rendering](#-github-changelog-rendering) - [📝 GitHub Changelog Rendering](#-github-changelog-rendering)
- [🌗 Theme Switcher](#-theme-switcher) - [🌗 Theme Switcher](#-theme-switcher)
- [💎 SVG assets as components](#-svg-assets-as-components) - [💎 SVG assets as components](#-svg-assets-as-components)
- [astro-redirect-from](#astro-redirect-from) - [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. 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. 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) - the `loadAndFormatCollection()` helper in [`src/lib/astro.ts`](src/lib/astro.ts)
- output through [`src/components/Exif/`](src/components/Exif/) - 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 ### 🔍 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"> <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 ### 🕸 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/) - [`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. 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 If you want to know how this works, have a look at the respective component under
- [`src/components/Changelog/`](src/components/Changelog/) - [`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 ### 🌗 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: If you want to know how, have a look at the respective components:
- [`src/components/ThemeSwitch/`](src/components/ThemeSwitch/) - [`src/components/ThemeSwitch/`](src/components/ThemeSwitch/)
- [`src/stores/theme.ts`](src/stores/theme.ts)
### 💎 SVG assets as components ### 💎 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/) - [`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 ### 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 ## ✨ Development
```bash ```bash
@ -201,6 +209,9 @@ To run all unit tests:
```bash ```bash
npm run test:unit npm run test:unit
# watch mode
npm run test:unit:watch
``` ```
For End-to-End integration testing, ideally run against the production build: For End-to-End integration testing, ideally run against the production build:

980
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
"typecheck": "npm run typecheck:astro && npm run typecheck:tsc", "typecheck": "npm run typecheck:astro && npm run typecheck:tsc",
"prebuild": "run-p --silent --continue-on-error create:symlinks create:icons move:downloads", "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": "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'", "test:e2e": "playwright test --config './test/playwright.config.ts'",
"lint": "run-p --silent lint:js lint:css lint:md", "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}'", "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", "create:symlinks": "./scripts/create-symlinks.sh",
"move:downloads": "ts-node --esm scripts/move-downloads.ts", "move:downloads": "ts-node --esm scripts/move-downloads.ts",
"prepare": "husky install .config/husky", "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": { "dependencies": {
"@astrojs/check": "^0.2.1", "@astrojs/check": "^0.3.0",
"@astrojs/react": "^3.0.4", "@astrojs/react": "^3.0.4",
"@astrojs/rss": "^3.0.0", "@astrojs/rss": "^3.0.0",
"@astrojs/sitemap": "^3.0.2", "@astrojs/sitemap": "^3.0.3",
"@nanostores/query": "^0.2.4", "@coingecko/cryptoformat": "^0.7.0",
"@nanostores/persistent": "^0.9.1",
"@nanostores/query": "^0.2.8",
"@nanostores/react": "^0.7.1", "@nanostores/react": "^0.7.1",
"@rainbow-me/rainbowkit": "^1.1.3", "@radix-ui/react-select": "^2.0.0",
"astro": "3.3.4", "@rainbow-me/rainbowkit": "^1.2.0",
"astro": "3.4.2",
"astro-expressive-code": "^0.26.2", "astro-expressive-code": "^0.26.2",
"astro-redirect-from": "^1.0.4", "astro-redirect-from": "^1.0.4",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
@ -46,15 +67,15 @@
"fast-exif": "^2.0.1", "fast-exif": "^2.0.1",
"feather-icons": "^4.29.1", "feather-icons": "^4.29.1",
"fraction.js": "^4.3.7", "fraction.js": "^4.3.7",
"fuse.js": "^6.6.2", "fuse.js": "^7.0.0",
"motion": "^10.16.4", "motion": "^10.16.4",
"nanostores": "^0.9.4", "nanostores": "^0.9.4",
"pigeon-maps": "^0.21.3", "pigeon-maps": "^0.21.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"use-debounce": "^9.0.4", "swr": "^2.2.4",
"viem": "^1.16.6", "viem": "^1.18.2",
"wagmi": "^1.4.5" "wagmi": "^1.4.5"
}, },
"devDependencies": { "devDependencies": {

View File

@ -9,7 +9,7 @@ afterAll(() => {
fs.rm(path.resolve(__dirname, 'tmp'), { recursive: true }) 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 // Act
await generateIcons(distDir) 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') throw new Error('Props.d.ts does not exist')
} }
// Assert: Check if an example Astro component exists // Assert: Check if an example Astro & React component exists
const exampleComponentPath = path.join(distDir, 'Bitcoin.astro') const exampleComponentPathAstro = path.join(distDir, 'Bitcoin.astro')
const exampleComponentPathReact = path.join(distDir, 'react', 'Bitcoin.tsx')
try { try {
await fs.stat(exampleComponentPath) await fs.stat(exampleComponentPathAstro)
} catch (err) { } catch (err) {
throw new Error( 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}`
) )
} }
}) })

View File

@ -6,7 +6,9 @@ import fs from 'node:fs/promises'
import ps from 'node:path/posix' import ps from 'node:path/posix'
import ora from 'ora' import ora from 'ora'
import chalk from 'chalk' 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. // Current directory.
const currentDir = ps.resolve('.') const currentDir = ps.resolve('.')
@ -31,15 +33,21 @@ export async function generateIcons(distDir: string) {
// clean the distribution directory // clean the distribution directory
await fs.rm(distDir, { force: true, recursive: true }) await fs.rm(distDir, { force: true, recursive: true })
await fs.mkdir(distDir, { recursive: true }) await fs.mkdir(distDir, { recursive: true })
await fs.mkdir(`${distDir}/react`, { recursive: true })
// copy the attribute typings file // copy the attribute typings file
await fs.copyFile( await fs.copyFile(
ps.resolve(currentDir, 'scripts/create-icons/Props.d.ts'), ps.resolve(currentDir, 'scripts/create-icons/Props.d.ts'),
ps.resolve(distDir, '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 contentOfIndexJS = '// @ts-nocheck\n'
let contentOfIndexReactJS = '// @ts-nocheck\n'
for (const src of srcDirs) { for (const src of srcDirs) {
for (let filepath of await fs.readdir(src, { encoding: 'utf8' })) { for (let filepath of await fs.readdir(src, { encoding: 'utf8' })) {
@ -83,16 +91,33 @@ export async function generateIcons(distDir: string) {
'utf8' '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 // add the astro component export to the main entry `index.ts` file
contentOfIndexJS += `\nexport { default as ${baseName} } from './${baseName}.astro'` 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 }) 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') 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( spinner.succeed(
`${chalk.bold('[create-icons]')} Generated ${ `${chalk.bold('[create-icons]')} Generated ${
icons.length icons.length

View File

@ -1,43 +1,5 @@
import { optimize as optimizeSVGNative } from 'svgo' 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) => export const toInnerSvg = (input: string) =>
optimizeSVGNative(input, { optimizeSVGNative(input, {
plugins: [ plugins: [

View 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>`

View 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>
}
`

View 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>

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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;
}
}

View File

@ -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"
/>
)
})
})

View File

@ -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)}`
}}
/>
)
}

View File

@ -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);
}

View File

@ -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" />)
})
})

View File

@ -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>
)
}

View File

@ -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;
}
}

View File

@ -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} />
</>
)
}

View File

@ -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;
}
}

View File

@ -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>
)
}

View File

@ -8,7 +8,7 @@ const year = new Date().getFullYear()
const { name, url, github } = config.author const { name, url, github } = config.author
--- ---
<footer role="contentinfo" class={styles.footer}> <footer role="contentinfo" class={styles.footer} id="footer">
<Vcard /> <Vcard />
<section class={styles.copyright}> <section class={styles.copyright}>
<p> <p>
@ -18,12 +18,10 @@ const { name, url, github } = config.author
{name} {name}
</a> </a>
<a href={`${github}/blog`}> <a href={`${github}/blog`}>
<Github /> <Github />View source
View source
</a> </a>
<a href="/thanks/"> <a href="/thanks/">
<Bitcoin /> <Bitcoin />Say Thanks
Say Thanks
</a> </a>
</p> </p>
</section> </section>

View File

@ -10,7 +10,6 @@ const { props } = Astro
position: relative; position: relative;
transform: rotate(0deg); transform: rotate(0deg);
cursor: pointer; cursor: pointer;
margin-top: calc(var(--spacer) / 2);
} }
.line { .line {
@ -57,11 +56,7 @@ const { props } = Astro
} }
.button { .button {
padding: calc(var(--spacer) / 2); margin-bottom: -0.4rem;
vertical-align: middle;
display: inline-block;
margin: 0;
margin-right: -1rem;
} }
.button:hover, .button:hover,

View File

@ -1,12 +1,12 @@
--- ---
import Menu from '@components/Menu/index.astro' 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 ThemeSwitch from '@components/ThemeSwitch/index.astro'
import { Logo } from '@images/components' import { Logo } from '@images/components'
import styles from './index.module.css' 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}> <div class={styles.headerContent}>
<a href="/" class={styles.title}> <a href="/" class={styles.title}>
<Logo class={styles.logo} viewBox="0 0 191 36" /> kremalicious <Logo class={styles.logo} viewBox="0 0 191 36" /> kremalicious

View File

@ -6,6 +6,7 @@
top: 0; top: 0;
border: 0; border: 0;
will-change: transform; will-change: transform;
padding-top: calc(var(--spacer) / 3);
} }
} }
@ -56,5 +57,23 @@
} }
.nav { .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;
} }

View File

@ -1,6 +1,5 @@
.input { .input {
display: block; display: block;
width: 100%;
padding: var(--padding-base-vertical) var(--padding-base-horizontal); padding: var(--padding-base-vertical) var(--padding-base-horizontal);
font-size: var(--input-font-size); font-size: var(--input-font-size);
font-weight: var(--input-font-weight); font-weight: var(--input-font-weight);
@ -25,7 +24,8 @@
/* stylelint-enable selector-no-vendor-prefix */ /* stylelint-enable selector-no-vendor-prefix */
.input:focus { .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; outline: 0;
} }

View File

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

View File

@ -0,0 +1,8 @@
import styles from './Loader.module.css'
import { 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} />
}

View File

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

View File

@ -1,8 +1,5 @@
.themeSwitch { .themeSwitch {
position: relative; position: relative;
display: inline-block;
vertical-align: middle;
margin-right: calc(var(--spacer) / 4);
} }
.themeSwitch svg { .themeSwitch svg {
@ -19,7 +16,6 @@
.checkbox { .checkbox {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
padding: calc(var(--spacer) / 2);
display: block; display: block;
} }

View File

@ -1,6 +1,6 @@
const htmlEl = document.documentElement const htmlEl = document.documentElement
const themeToggle = document.querySelector('#toggle') const themeToggle = document.querySelector('#toggle')
const currentTheme = localStorage.getItem('theme') const currentTheme = localStorage.getItem('@kremalicious/theme')
function getPreferTheme() { function getPreferTheme() {
if (currentTheme) return currentTheme if (currentTheme) return currentTheme
@ -18,7 +18,7 @@ let themeValue = getPreferTheme()
let themeColor = getThemeColor(themeValue) let themeColor = getThemeColor(themeValue)
function setPreference() { function setPreference() {
localStorage.setItem('theme', themeValue) localStorage.setItem('@kremalicious/theme', themeValue)
reflectPreference() reflectPreference()
} }

View File

@ -21,14 +21,6 @@ import Search from './Search.tsx'
</script> </script>
<style> <style>
.searchButton {
padding: calc(var(--spacer) / 2);
vertical-align: middle;
display: inline-block;
margin: 0;
margin-right: calc(var(--spacer) / 4);
}
.searchButton:focus { .searchButton:focus {
outline: 0; outline: 0;
} }

View File

@ -0,0 +1,8 @@
.conversion {
font-size: var(--font-size-mini);
color: var(--text-color-light);
}
.conversion span {
margin-left: calc(var(--spacer) / 3);
}

View File

@ -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 />)
})
})

View 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>
)
}

View File

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

View 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;
}

View File

@ -1,9 +1,9 @@
import { test, expect } from 'vitest' import { test, expect } from 'vitest'
import { render, fireEvent, screen, waitFor } from '@testing-library/react' import { render, fireEvent, screen } from '@testing-library/react'
import Web3Donation from '.' import { Web3Form } from './Form'
test('Web3Donation component', async () => { test('Web3Donation component', async () => {
render(<Web3Donation address="0x456" />) render(<Web3Form />)
const submitButton = screen.getByRole('button') const submitButton = screen.getByRole('button')
expect(submitButton).toBeInTheDocument() expect(submitButton).toBeInTheDocument()
@ -18,10 +18,10 @@ test('Web3Donation component', async () => {
expect(input).toHaveValue('1') expect(input).toHaveValue('1')
// Simulate form submission // Simulate form submission
fireEvent.click(submitButton) // fireEvent.click(submitButton)
await waitFor(() => { // await waitFor(() => {
const alert = screen.getByText(/Waiting for network confirmation/i) // const alert = screen.getByText(/Waiting for network confirmation/i)
expect(alert).toBeInTheDocument() // expect(alert).toBeInTheDocument()
}) // })
}) })

View 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>
)
}

View File

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

View 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);
}
}

View File

@ -1,39 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react' import { fireEvent, render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect } from 'vitest'
import InputGroup from './InputGroup' import { InputGroup } from '.'
const setAmount = vi.fn()
describe('InputGroup', () => { describe('InputGroup', () => {
it('renders without crashing', async () => { it('renders without crashing', async () => {
render( render(<InputGroup isDisabled={false} error={undefined} />)
<InputGroup
amount="1"
setAmount={setAmount}
isDisabled={false}
symbol="ETH"
/>
)
const input = await screen.findByRole('textbox') const input = await screen.findByRole('textbox')
const button = await screen.findByRole('button') const button = await screen.findByRole('button')
fireEvent.change(input, { target: { value: '3' } }) fireEvent.change(input, { target: { value: '3' } })
fireEvent.click(button) fireEvent.click(button)
expect(setAmount).toHaveBeenCalled()
}) })
it('renders disabled', async () => { it('renders disabled', async () => {
render( render(<InputGroup isDisabled={true} error={undefined} />)
<InputGroup
amount="1"
setAmount={setAmount}
isDisabled={true}
symbol="ETH"
/>
)
const input = await screen.findByRole('textbox') const input = await screen.findByRole('textbox')
expect(input).toBeDefined() expect(input).toBeDefined()
expect(input.attributes.getNamedItem('disabled')).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()
})
}) })

View 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>
</>
)
}

View File

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

View 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;
}

View 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>
)
}

View 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;
}

View 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()
})
})

View 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>
</>
)
}

View File

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

View File

@ -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;
}

View 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>
)
}

View 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 />
}

View File

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

View 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>
)
}

View 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);
}

View 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()
})
})

View 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>
)
}

View File

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

View 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;
}

View 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
}
)

View 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;
}

View 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
}

View File

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

View 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>
)
}

View File

@ -0,0 +1,2 @@
export * from './useFetchTokens'
export * from './types'

View 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
}
}

View File

@ -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()
})

View 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
}

View 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'
}
]

View File

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

View 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()
})

View 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
}

View 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 }
}

View File

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

View File

@ -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)
})

View 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)
)
}

View 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()
})

View 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
}

View 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 }
}

View File

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

View File

@ -1,6 +1,6 @@
import { type Theme, getDefaultWallets } from '@rainbow-me/rainbowkit' import { type Theme, getDefaultWallets } from '@rainbow-me/rainbowkit'
import { configureChains, createConfig } from 'wagmi' 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 { infuraProvider } from 'wagmi/providers/infura'
import { publicProvider } from 'wagmi/providers/public' import { publicProvider } from 'wagmi/providers/public'
@ -13,7 +13,7 @@ if (isProduction && (!PUBLIC_INFURA_ID || !PUBLIC_WALLETCONNECT_ID)) {
} }
export const { chains, publicClient } = configureChains( export const { chains, publicClient } = configureChains(
[mainnet, polygon], [mainnet, polygon, base, optimism],
[infuraProvider({ apiKey: PUBLIC_INFURA_ID }), publicProvider()] [infuraProvider({ apiKey: PUBLIC_INFURA_ID }), publicProvider()]
) )
@ -29,16 +29,16 @@ export const wagmiConfig = createConfig({
publicClient publicClient
}) })
export const theme = { export const theme: Theme = {
colors: { colors: {
accentColor: 'var(--brand-cyan)', accentColor: 'var(--brand-cyan)',
accentColorForeground: '#161a1b', accentColorForeground: '#161a1b',
actionButtonBorder: 'var(--body-background-color)', actionButtonBorder: 'var(--border-color)',
actionButtonBorderMobile: 'var(--body-background-color)', actionButtonBorderMobile: 'var(--border-color)',
actionButtonSecondaryBackground: 'var(--box-background-color)', actionButtonSecondaryBackground: 'var(--box-background-color)',
closeButton: 'var(--text-color)', closeButton: 'var(--text-color)',
closeButtonBackground: 'var(--box-background-color)', closeButtonBackground: 'var(--box-background-color)',
connectButtonBackground: 'var(--body-background-color)', connectButtonBackground: 'transparent',
connectButtonBackgroundError: 'var(--alert-error)', connectButtonBackgroundError: 'var(--alert-error)',
connectButtonInnerBackground: 'var(--box-background-color)', connectButtonInnerBackground: 'var(--box-background-color)',
connectButtonText: 'var(--text-color)', connectButtonText: 'var(--text-color)',
@ -47,10 +47,10 @@ export const theme = {
error: 'var(--alert-error)', error: 'var(--alert-error)',
generalBorder: 'var(--border-color)', generalBorder: 'var(--border-color)',
generalBorderDim: 'var(--border-color)', generalBorderDim: 'var(--border-color)',
menuItemBackground: 'var(--link-color)', menuItemBackground: 'var(--border-color)',
modalBackdrop: 'rgba(0, 0, 0, 0.5)', modalBackdrop: 'rgba(0, 0, 0, 0.5)',
modalBackground: 'var(--body-background-color)', modalBackground: 'var(--body-background-color)',
modalBorder: 'var(--body-background-color)', modalBorder: 'var(--border-color)',
modalText: 'var(--text-color)', modalText: 'var(--text-color)',
modalTextDim: 'var(--text-color-dimmed)', modalTextDim: 'var(--text-color-dimmed)',
modalTextSecondary: 'var(--text-color-light)', modalTextSecondary: 'var(--text-color-light)',
@ -58,7 +58,9 @@ export const theme = {
profileActionHover: 'var(--box-background-color)', profileActionHover: 'var(--box-background-color)',
profileForeground: 'var(--body-background-color)', profileForeground: 'var(--body-background-color)',
selectedOptionBorder: 'var(--border-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: { fonts: {
body: 'var(--font-family-base)' body: 'var(--font-family-base)'
@ -71,11 +73,14 @@ export const theme = {
modalMobile: 'var(--border-radius)' modalMobile: 'var(--border-radius)'
}, },
shadows: { shadows: {
connectButton: 'var(--box-shadow)', connectButton: 'none',
dialog: 'var(--box-shadow)', dialog: 'var(--box-shadow)',
profileDetailsAction: 'none', profileDetailsAction: 'none',
selectedOption: 'var(--box-shadow)', selectedOption: 'none',
selectedWallet: 'var(--box-shadow)', selectedWallet: 'none',
walletLogo: 'var(--box-shadow)' walletLogo: 'var(--box-shadow)'
},
blurs: {
modalOverlay: 'initial'
} }
} as Theme }

View File

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

View File

@ -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')
})

View 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
)}`
}

View File

@ -0,0 +1,2 @@
export * from './selectedToken'
export * from './send'

View 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