@1001-digital/components 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1001-digital/components",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "*.css"
@@ -13,16 +13,12 @@
13
13
  "src"
14
14
  ],
15
15
  "peerDependencies": {
16
- "@wagmi/core": ">=3.0.0",
17
- "@wagmi/vue": ">=0.4.0",
16
+ "@wagmi/vue": ">=0.5.0",
18
17
  "viem": ">=2.0.0",
19
18
  "vue": "^3.5.0",
20
- "@1001-digital/styles": "^0.0.1"
19
+ "@1001-digital/styles": "^0.0.2"
21
20
  },
22
21
  "peerDependenciesMeta": {
23
- "@wagmi/core": {
24
- "optional": true
25
- },
26
22
  "@wagmi/vue": {
27
23
  "optional": true
28
24
  },
@@ -250,9 +250,10 @@ const onClickOutside = () => {
250
250
  > footer {
251
251
  display: flex;
252
252
  gap: var(--spacer);
253
- justify-content: flex-end;
253
+ justify-content: safe flex-end;
254
254
  padding: var(--spacer);
255
255
  border-block-start: var(--border);
256
+ overflow-x: auto;
256
257
 
257
258
  &:empty {
258
259
  display: none;
@@ -13,6 +13,11 @@
13
13
  @update:open="(open) => !open && onClose(toast.id)"
14
14
  >
15
15
  <ToastTitle class="toast-title">
16
+ <span
17
+ v-if="toast.loading"
18
+ class="toast-spinner"
19
+ aria-hidden="true"
20
+ />
16
21
  {{ toast.title }}
17
22
  </ToastTitle>
18
23
  <ToastClose
@@ -37,7 +42,7 @@
37
42
  class="left"
38
43
  >
39
44
  <Button
40
- class="small"
45
+ class="small tertiary"
41
46
  @click="toast.action!.onClick()"
42
47
  >
43
48
  {{ toast.action.label }}
@@ -211,6 +216,7 @@ const onClose = (id: string) => {
211
216
  .toast-title {
212
217
  display: flex;
213
218
  align-items: center;
219
+ gap: var(--spacer-sm);
214
220
  block-size: calc(var(--spacer) * 2);
215
221
  box-shadow: var(--border-shadow);
216
222
  padding-inline-start: var(--ui-padding-inline);
@@ -222,6 +228,16 @@ const onClose = (id: string) => {
222
228
  margin: 0;
223
229
  }
224
230
 
231
+ .toast-spinner {
232
+ width: var(--ui-font-size);
233
+ height: var(--ui-font-size);
234
+ border: 2px solid currentColor;
235
+ border-top-color: transparent;
236
+ border-radius: 50%;
237
+ animation: spin var(--speed-slow, 1s) linear infinite;
238
+ flex-shrink: 0;
239
+ }
240
+
225
241
  > section {
226
242
  padding: var(--ui-padding-inline);
227
243
  display: grid;
@@ -250,5 +266,11 @@ const onClose = (id: string) => {
250
266
  }
251
267
  }
252
268
  }
269
+
270
+ @keyframes spin {
271
+ to {
272
+ transform: rotate(360deg);
273
+ }
274
+ }
253
275
  }
254
276
  </style>
@@ -14,6 +14,7 @@ export interface Toast {
14
14
  variant?: ToastVariant
15
15
  action?: ToastAction
16
16
  duration?: number
17
+ loading?: boolean
17
18
  }
18
19
 
19
20
  const toasts = ref<Toast[]>([])
@@ -22,13 +22,7 @@
22
22
  :txt="text.lead[step] || ''"
23
23
  />
24
24
 
25
- <p
26
- v-if="
27
- step !== 'requesting' &&
28
- step !== 'error' &&
29
- text.lead[step]
30
- "
31
- >
25
+ <p v-if="step !== 'requesting' && step !== 'error' && text.lead[step]">
32
26
  {{ text.lead[step] }}
33
27
  </p>
34
28
 
@@ -203,9 +197,7 @@ onBeforeUnmount(() => {
203
197
  })
204
198
 
205
199
  const canDismiss = computed(
206
- () =>
207
- props.dismissable &&
208
- step.value !== 'requesting',
200
+ () => props.dismissable && step.value !== 'requesting',
209
201
  )
210
202
 
211
203
  const initializeRequest = async (request = cachedRequest.value) => {
@@ -245,6 +237,7 @@ const initializeRequest = async (request = cachedRequest.value) => {
245
237
  title: text.value.title.waiting,
246
238
  description: text.value.lead.waiting,
247
239
  duration: Infinity,
240
+ loading: true,
248
241
  action: {
249
242
  label: 'View on Block Explorer',
250
243
  onClick: () => window.open(link, '_blank'),
@@ -264,6 +257,7 @@ const initializeRequest = async (request = cachedRequest.value) => {
264
257
  variant: 'success',
265
258
  title: text.value.title.complete,
266
259
  description: text.value.lead.complete,
260
+ loading: false,
267
261
  ...(props.autoCloseSuccess && { duration: props.delayAutoclose }),
268
262
  })
269
263
  } catch (e: unknown) {
@@ -277,6 +271,7 @@ const initializeRequest = async (request = cachedRequest.value) => {
277
271
  variant: 'error',
278
272
  title: text.value.title.error,
279
273
  description: err.shortMessage || 'Transaction failed.',
274
+ loading: false,
280
275
  })
281
276
  }
282
277
  console.log(e)
@@ -0,0 +1,107 @@
1
+ import { getPublicClient } from '@wagmi/core'
2
+ import type { Config } from '@wagmi/vue'
3
+ import {
4
+ ensCache,
5
+ fetchEnsFromIndexer,
6
+ fetchEnsFromChain,
7
+ ENS_KEYS_AVATAR,
8
+ ENS_KEYS_PROFILE,
9
+ } from '../utils/ens'
10
+ import type { EnsProfile } from '../utils/ens'
11
+
12
+ type EnsMode = 'indexer' | 'chain'
13
+
14
+ interface UseEnsOptions {
15
+ mode?: MaybeRefOrGetter<EnsMode | undefined>
16
+ }
17
+
18
+ interface EnsRuntimeConfig {
19
+ ens?: { indexer1?: string; indexer2?: string; indexer3?: string }
20
+ }
21
+
22
+ function getIndexerUrls(config: EnsRuntimeConfig): string[] {
23
+ if (!config.ens) return []
24
+ return [config.ens.indexer1, config.ens.indexer2, config.ens.indexer3].filter(
25
+ Boolean,
26
+ ) as string[]
27
+ }
28
+
29
+ async function resolve(
30
+ identifier: string,
31
+ strategies: EnsMode[],
32
+ indexerUrls: string[],
33
+ wagmi: Config,
34
+ chainKeys: string[],
35
+ ): Promise<EnsProfile> {
36
+ for (const strategy of strategies) {
37
+ try {
38
+ if (strategy === 'indexer') {
39
+ if (!indexerUrls.length) continue
40
+ return await fetchEnsFromIndexer(identifier, indexerUrls)
41
+ }
42
+
43
+ if (strategy === 'chain') {
44
+ const client = getPublicClient(wagmi, { chainId: 1 })
45
+ if (!client) continue
46
+ return await fetchEnsFromChain(identifier, client, chainKeys)
47
+ }
48
+ } catch {
49
+ continue
50
+ }
51
+ }
52
+
53
+ return { address: identifier, ens: null, data: null }
54
+ }
55
+
56
+ function useEnsBase(
57
+ tier: string,
58
+ identifier: MaybeRefOrGetter<string | undefined>,
59
+ chainKeys: string[],
60
+ options: UseEnsOptions = {},
61
+ ) {
62
+ const { $wagmi } = useNuxtApp()
63
+ const appConfig = useAppConfig()
64
+ const runtimeConfig = useRuntimeConfig()
65
+
66
+ const mode = computed<EnsMode>(
67
+ () => toValue(options.mode) || appConfig.evm?.ens?.mode || 'indexer',
68
+ )
69
+ const indexerUrls = computed(() =>
70
+ getIndexerUrls(runtimeConfig.public.evm as EnsRuntimeConfig),
71
+ )
72
+ const cacheKey = computed(() => `ens-${tier}-${toValue(identifier)}`)
73
+
74
+ return useAsyncData(
75
+ cacheKey.value,
76
+ async () => {
77
+ const id = toValue(identifier)
78
+ if (!id) return null
79
+
80
+ const strategies: EnsMode[] =
81
+ mode.value === 'indexer' ? ['indexer', 'chain'] : ['chain', 'indexer']
82
+
83
+ return ensCache.fetch(cacheKey.value, () =>
84
+ resolve(id, strategies, indexerUrls.value, $wagmi as Config, chainKeys),
85
+ )
86
+ },
87
+ {
88
+ watch: [() => toValue(identifier)],
89
+ getCachedData: () => ensCache.get(cacheKey.value) ?? undefined,
90
+ },
91
+ )
92
+ }
93
+
94
+ export const useEns = (
95
+ identifier: MaybeRefOrGetter<string | undefined>,
96
+ options?: UseEnsOptions,
97
+ ) => useEnsBase('resolve', identifier, [], options)
98
+
99
+ export const useEnsWithAvatar = (
100
+ identifier: MaybeRefOrGetter<string | undefined>,
101
+ options?: UseEnsOptions,
102
+ ) => useEnsBase('avatar', identifier, [...ENS_KEYS_AVATAR], options)
103
+
104
+ export const useEnsProfile = (
105
+ identifier: MaybeRefOrGetter<string | undefined>,
106
+ options?: UseEnsOptions,
107
+ ) => useEnsBase('profile', identifier, [...ENS_KEYS_PROFILE], options)
@@ -0,0 +1,37 @@
1
+ import { ref, computed, watch, type Ref, type WatchStopHandle } from 'vue'
2
+ import { formatEther, formatGwei } from 'viem'
3
+ import { getGasPrice } from '@wagmi/core'
4
+ import { useConfig, useBlockNumber } from '@wagmi/vue'
5
+
6
+ let priceWatcher: WatchStopHandle | null = null
7
+ const price: Ref<bigint> = ref(0n)
8
+
9
+ export const useGasPrice = () => {
10
+ const config = useConfig()
11
+ const { data: blockNumber } = useBlockNumber()
12
+
13
+ const updatePrice = async () => {
14
+ price.value = await getGasPrice(config)
15
+ }
16
+
17
+ if (!priceWatcher) {
18
+ updatePrice()
19
+ priceWatcher = watch(blockNumber, () => updatePrice())
20
+ }
21
+
22
+ const unitPrice = computed(() => ({
23
+ wei: price.value,
24
+ gwei: formatGwei(price.value),
25
+ eth: formatEther(price.value),
26
+
27
+ formatted: {
28
+ gwei:
29
+ price.value > 2_000_000_000_000n
30
+ ? Math.round(parseFloat(formatGwei(price.value)))
31
+ : parseFloat(formatGwei(price.value)).toFixed(1),
32
+ eth: formatEther(price.value),
33
+ },
34
+ }))
35
+
36
+ return unitPrice
37
+ }
@@ -0,0 +1,116 @@
1
+ import { reactive, computed } from 'vue'
2
+ import { readContract } from '@wagmi/core'
3
+ import { useConfig } from '@wagmi/vue'
4
+ import { parseJSON, stringifyJSON, formatPrice } from '../utils/price'
5
+ import { nowInSeconds } from '../../base/utils/time'
6
+
7
+ const CHAINLINK_ETH_USD_ABI = [
8
+ {
9
+ inputs: [],
10
+ name: 'latestRoundData',
11
+ outputs: [
12
+ { internalType: 'uint80', name: 'roundId', type: 'uint80' },
13
+ { internalType: 'int256', name: 'answer', type: 'int256' },
14
+ { internalType: 'uint256', name: 'startedAt', type: 'uint256' },
15
+ { internalType: 'uint256', name: 'updatedAt', type: 'uint256' },
16
+ { internalType: 'uint80', name: 'answeredInRound', type: 'uint80' },
17
+ ],
18
+ stateMutability: 'view',
19
+ type: 'function',
20
+ },
21
+ ] as const
22
+
23
+ const CHAINLINK_ETH_USD = '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419'
24
+ const STORAGE_KEY = 'evm:price-feed'
25
+ const CACHE_TTL = 3_600 // 1 hour in seconds
26
+
27
+ interface PriceFeedState {
28
+ ethUSDRaw: bigint | null
29
+ lastUpdated: number
30
+ }
31
+
32
+ const state = reactive<PriceFeedState>({
33
+ ethUSDRaw: null,
34
+ lastUpdated: 0,
35
+ })
36
+
37
+ function loadFromStorage() {
38
+ if (typeof window === 'undefined') return
39
+
40
+ try {
41
+ const stored = localStorage.getItem(STORAGE_KEY)
42
+ if (!stored) return
43
+
44
+ const parsed = parseJSON(stored) as PriceFeedState
45
+ if (parsed.ethUSDRaw) state.ethUSDRaw = parsed.ethUSDRaw
46
+ if (parsed.lastUpdated) state.lastUpdated = parsed.lastUpdated
47
+ } catch {
48
+ // Ignore corrupted storage
49
+ }
50
+ }
51
+
52
+ function saveToStorage() {
53
+ if (typeof window === 'undefined') return
54
+
55
+ try {
56
+ localStorage.setItem(
57
+ STORAGE_KEY,
58
+ stringifyJSON({
59
+ ethUSDRaw: state.ethUSDRaw,
60
+ lastUpdated: state.lastUpdated,
61
+ }),
62
+ )
63
+ } catch {
64
+ // Ignore storage errors
65
+ }
66
+ }
67
+
68
+ export const usePriceFeed = () => {
69
+ const config = useConfig()
70
+
71
+ // Load cached data on first use
72
+ if (!state.lastUpdated) loadFromStorage()
73
+
74
+ const ethUSD = computed(() =>
75
+ state.ethUSDRaw ? state.ethUSDRaw / BigInt(1e8) : 0n,
76
+ )
77
+ const ethUSC = computed(() =>
78
+ state.ethUSDRaw ? state.ethUSDRaw / BigInt(1e6) : 0n,
79
+ )
80
+ const ethUSDFormatted = computed(() =>
81
+ formatPrice(Number(ethUSC.value) / 100, 2),
82
+ )
83
+
84
+ const weiToUSD = (wei: bigint) => {
85
+ const cents = (wei * (state.ethUSDRaw || 0n)) / 10n ** 18n / 10n ** 6n
86
+ return formatPrice(Number(cents) / 100, 2)
87
+ }
88
+
89
+ async function fetchPrice() {
90
+ if (nowInSeconds() - state.lastUpdated < CACHE_TTL) return
91
+
92
+ try {
93
+ const [, answer] = await readContract(config, {
94
+ address: CHAINLINK_ETH_USD,
95
+ abi: CHAINLINK_ETH_USD_ABI,
96
+ functionName: 'latestRoundData',
97
+ chainId: 1,
98
+ })
99
+
100
+ state.ethUSDRaw = answer
101
+ state.lastUpdated = nowInSeconds()
102
+ saveToStorage()
103
+ } catch (error) {
104
+ console.warn('Error fetching ETH/USD price:', error)
105
+ }
106
+ }
107
+
108
+ return {
109
+ ethUSDRaw: computed(() => state.ethUSDRaw),
110
+ ethUSD,
111
+ ethUSC,
112
+ ethUSDFormatted,
113
+ weiToUSD,
114
+ fetchPrice,
115
+ }
116
+ }
package/src/evm/index.ts CHANGED
@@ -3,9 +3,19 @@ export { EvmConfigKey, defaultEvmConfig, useEvmConfig } from './config'
3
3
  export type { EvmConfig, EvmChainConfig } from './config'
4
4
 
5
5
  // Utils
6
+ export { createCache } from './utils/cache'
6
7
  export { shortAddress } from './utils/addresses'
7
8
  export { resolveChain } from './utils/chains'
8
9
  export { formatETH } from './utils/format-eth'
10
+ export {
11
+ ensCache,
12
+ fetchEnsFromIndexer,
13
+ fetchEnsFromChain,
14
+ ENS_KEYS_AVATAR,
15
+ ENS_KEYS_PROFILE,
16
+ } from './utils/ens'
17
+ export type { EnsProfile } from './utils/ens'
18
+ export { stringifyJSON, parseJSON, formatPrice } from './utils/price'
9
19
 
10
20
  // Composables
11
21
  export { useBaseURL } from './composables/base'
@@ -15,6 +25,9 @@ export {
15
25
  useBlockExplorer,
16
26
  useEnsureChainIdCheck,
17
27
  } from './composables/chainId'
28
+ export { useEns, useEnsWithAvatar, useEnsProfile } from './composables/ens'
29
+ export { useGasPrice } from './composables/gasPrice'
30
+ export { usePriceFeed } from './composables/priceFeed'
18
31
 
19
32
  // Components
20
33
  export { default as EvmAccount } from './components/EvmAccount.vue'
@@ -0,0 +1,59 @@
1
+ interface CacheEntry<T> {
2
+ data: T
3
+ expiresAt: number
4
+ }
5
+
6
+ export function createCache<T>(ttl: number, max: number) {
7
+ const entries = new Map<string, CacheEntry<T>>()
8
+ const pending = new Map<string, Promise<T>>()
9
+
10
+ function prune() {
11
+ const now = Date.now()
12
+ for (const [key, entry] of entries) {
13
+ if (entry.expiresAt <= now) entries.delete(key)
14
+ }
15
+
16
+ if (entries.size > max) {
17
+ const excess = entries.size - max
18
+ const keys = entries.keys()
19
+ for (let i = 0; i < excess; i++) {
20
+ entries.delete(keys.next().value!)
21
+ }
22
+ }
23
+ }
24
+
25
+ function get(key: string): T | undefined {
26
+ const entry = entries.get(key)
27
+ if (!entry) return undefined
28
+ if (entry.expiresAt <= Date.now()) {
29
+ entries.delete(key)
30
+ return undefined
31
+ }
32
+ return entry.data
33
+ }
34
+
35
+ function fetch(key: string, fn: () => Promise<T>): Promise<T> {
36
+ const cached = get(key)
37
+ if (cached) return Promise.resolve(cached)
38
+
39
+ const inflight = pending.get(key)
40
+ if (inflight) return inflight
41
+
42
+ const promise = fn()
43
+ .then((result) => {
44
+ entries.set(key, { data: result, expiresAt: Date.now() + ttl })
45
+ pending.delete(key)
46
+ if (entries.size > max) prune()
47
+ return result
48
+ })
49
+ .catch((err) => {
50
+ pending.delete(key)
51
+ throw err
52
+ })
53
+
54
+ pending.set(key, promise)
55
+ return promise
56
+ }
57
+
58
+ return { get, fetch }
59
+ }
@@ -0,0 +1,114 @@
1
+ import { isAddress, type PublicClient, type Address } from 'viem'
2
+ import { normalize } from 'viem/ens'
3
+ import { createCache } from './cache'
4
+
5
+ // --- Types ---
6
+
7
+ export interface EnsProfile {
8
+ address: string
9
+ ens: string | null
10
+ data: {
11
+ avatar: string
12
+ header: string
13
+ description: string
14
+ links: {
15
+ url: string
16
+ email: string
17
+ twitter: string
18
+ github: string
19
+ }
20
+ } | null
21
+ }
22
+
23
+ // --- Text record keys ---
24
+
25
+ const ALL_KEYS = [
26
+ 'avatar',
27
+ 'header',
28
+ 'description',
29
+ 'url',
30
+ 'email',
31
+ 'com.twitter',
32
+ 'com.github',
33
+ ] as const
34
+
35
+ export const ENS_KEYS_AVATAR = ['avatar'] as const
36
+ export const ENS_KEYS_PROFILE = [...ALL_KEYS]
37
+
38
+ // --- Cache ---
39
+
40
+ export const ensCache = createCache<EnsProfile>(5 * 60 * 1000, 500)
41
+
42
+ // --- Fetchers ---
43
+
44
+ export async function fetchEnsFromIndexer(
45
+ identifier: string,
46
+ urls: string[],
47
+ ): Promise<EnsProfile> {
48
+ let lastError: Error | undefined
49
+
50
+ for (const url of urls) {
51
+ try {
52
+ return await $fetch<EnsProfile>(`${url}/${identifier}`)
53
+ } catch (err) {
54
+ lastError = err as Error
55
+ }
56
+ }
57
+
58
+ throw lastError ?? new Error('No indexer URLs provided')
59
+ }
60
+
61
+ export async function fetchEnsFromChain(
62
+ identifier: string,
63
+ client: PublicClient,
64
+ keys: string[] = [],
65
+ ): Promise<EnsProfile> {
66
+ const isAddr = isAddress(identifier)
67
+
68
+ let address: string
69
+ let ens: string | null
70
+
71
+ if (isAddr) {
72
+ address = identifier
73
+ ens = (await client.getEnsName({ address: identifier as Address })) ?? null
74
+ } else {
75
+ ens = identifier
76
+ const resolved = await client.getEnsAddress({ name: normalize(identifier) })
77
+ if (!resolved) return { address: '', ens, data: null }
78
+ address = resolved
79
+ }
80
+
81
+ if (!ens || !keys.length) return { address, ens: ens ?? null, data: null }
82
+
83
+ const name = normalize(ens)
84
+ const results = await Promise.all(
85
+ keys.map((key) => client.getEnsText({ name, key }).catch(() => null)),
86
+ )
87
+
88
+ return {
89
+ address,
90
+ ens,
91
+ data: toProfileData(
92
+ keys,
93
+ results.map((r) => r || ''),
94
+ ),
95
+ }
96
+ }
97
+
98
+ // --- Helpers ---
99
+
100
+ function toProfileData(keys: string[], results: string[]): EnsProfile['data'] {
101
+ const get = (key: string) => results[keys.indexOf(key)] || ''
102
+
103
+ return {
104
+ avatar: get('avatar'),
105
+ header: get('header'),
106
+ description: get('description'),
107
+ links: {
108
+ url: get('url'),
109
+ email: get('email'),
110
+ twitter: get('com.twitter'),
111
+ github: get('com.github'),
112
+ },
113
+ }
114
+ }
@@ -0,0 +1,17 @@
1
+ const replacer = (_: string, value: unknown) => {
2
+ if (typeof value === 'bigint') return value.toString() + 'n'
3
+ return value
4
+ }
5
+
6
+ const reviver = (_: string, value: unknown) => {
7
+ if (typeof value === 'string' && /^\d+n$/.test(value))
8
+ return BigInt(value.slice(0, -1))
9
+ return value
10
+ }
11
+
12
+ export const stringifyJSON = (obj: unknown): string =>
13
+ JSON.stringify(obj, replacer)
14
+ export const parseJSON = (json: string): unknown => JSON.parse(json, reviver)
15
+
16
+ export const formatPrice = (num: number, digits: number = 2) =>
17
+ num?.toLocaleString('en-US', { maximumFractionDigits: digits })