1
0
Fork 0

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',
github: 'https://github.com/kremalicious',
bitcoin: '171qDmKEXm9YBgBLXyGjjPvopP5o9htQ1V',
ether: '0xf50F267b5689b005FE107cfdb34619f24c014457'
ether: {
ens: 'krema.eth',
address: '0xf50F267b5689b005FE107cfdb34619f24c014457'
}
},
rss: '/feed.xml',
jsonfeed: '/feed.json',

View File

@ -1,4 +1,4 @@
#!/usr/bin/env 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_WEBSITE_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_INFURA_ID: ${{ secrets.GATSBY_INFURA_ID }}
PUBLIC_WALLETCONNECT_ID: ${{ secrets.GATSBY_WALLETCONNECT_ID }}
PUBLIC_WEB3_API_URL: ${{ secrets.PUBLIC_WEB3_API_URL }}
jobs:
lint:

View File

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

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",
"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": {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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 { 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()
// })
})

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

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

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