@1001-digital/components 0.0.1

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.
Files changed (52) hide show
  1. package/package.json +45 -0
  2. package/src/base/components/Actions.vue +57 -0
  3. package/src/base/components/Alert.vue +90 -0
  4. package/src/base/components/Button.vue +260 -0
  5. package/src/base/components/Card.vue +78 -0
  6. package/src/base/components/CardLink.vue +56 -0
  7. package/src/base/components/Dialog.vue +274 -0
  8. package/src/base/components/Dropdown.vue +167 -0
  9. package/src/base/components/DropdownCheckboxItem.vue +30 -0
  10. package/src/base/components/DropdownGroup.vue +9 -0
  11. package/src/base/components/DropdownItem.vue +23 -0
  12. package/src/base/components/DropdownLabel.vue +9 -0
  13. package/src/base/components/DropdownRadioGroup.vue +15 -0
  14. package/src/base/components/DropdownRadioItem.vue +29 -0
  15. package/src/base/components/DropdownSeparator.vue +7 -0
  16. package/src/base/components/DropdownSub.vue +58 -0
  17. package/src/base/components/Form.vue +27 -0
  18. package/src/base/components/FormCheckbox.vue +92 -0
  19. package/src/base/components/FormGroup.vue +39 -0
  20. package/src/base/components/FormInputGroup.vue +55 -0
  21. package/src/base/components/FormItem.vue +89 -0
  22. package/src/base/components/FormLabel.vue +39 -0
  23. package/src/base/components/FormRadioGroup.vue +118 -0
  24. package/src/base/components/FormSelect.vue +160 -0
  25. package/src/base/components/FormTextarea.vue +38 -0
  26. package/src/base/components/Icon.vue +29 -0
  27. package/src/base/components/Loading.vue +81 -0
  28. package/src/base/components/Popover.vue +182 -0
  29. package/src/base/components/Tag.vue +56 -0
  30. package/src/base/components/Tags.vue +13 -0
  31. package/src/base/components/Toasts.vue +254 -0
  32. package/src/base/components/Tooltip.vue +100 -0
  33. package/src/base/composables/time.ts +82 -0
  34. package/src/base/composables/toast.ts +40 -0
  35. package/src/base/icons.ts +20 -0
  36. package/src/base/link.ts +4 -0
  37. package/src/base/utils/format-number.ts +29 -0
  38. package/src/base/utils/time.ts +20 -0
  39. package/src/evm/components/EvmAccount.vue +28 -0
  40. package/src/evm/components/EvmConnect.vue +254 -0
  41. package/src/evm/components/EvmConnectorQR.vue +116 -0
  42. package/src/evm/components/EvmMetaMaskQR.vue +15 -0
  43. package/src/evm/components/EvmTransactionFlow.vue +327 -0
  44. package/src/evm/components/EvmWalletConnectQR.vue +13 -0
  45. package/src/evm/composables/base.ts +7 -0
  46. package/src/evm/composables/chainId.ts +41 -0
  47. package/src/evm/config.ts +32 -0
  48. package/src/evm/index.ts +25 -0
  49. package/src/evm/utils/addresses.ts +6 -0
  50. package/src/evm/utils/chains.ts +32 -0
  51. package/src/evm/utils/format-eth.ts +15 -0
  52. package/src/index.ts +68 -0
@@ -0,0 +1,82 @@
1
+ import { ref, computed, watch } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import { DateTime } from 'luxon'
4
+ import { nowInSeconds } from '../utils/time'
5
+
6
+ let nowInterval: ReturnType<typeof setInterval> | undefined
7
+
8
+ const now = ref<number>(nowInSeconds())
9
+
10
+ export const useSeconds = () => {
11
+ if (typeof window !== 'undefined' && !nowInterval) {
12
+ nowInterval = setInterval(() => {
13
+ now.value = nowInSeconds()
14
+ }, 1000)
15
+ }
16
+
17
+ return now
18
+ }
19
+
20
+ export const useCountDown = (
21
+ s: Ref<number | bigint>,
22
+ showSecondsWithin: number = 60,
23
+ ) => {
24
+ const duration = computed(() => Math.abs(Number(s.value)))
25
+
26
+ const seconds = computed(() => duration.value % 60)
27
+ const minutes = computed(() => Math.floor(duration.value / 60) % 60)
28
+ const hours = computed(() => Math.floor(duration.value / 60 / 60) % 24)
29
+ const days = computed(() => Math.floor(duration.value / 60 / 60 / 24))
30
+
31
+ const str = computed(() =>
32
+ [
33
+ days.value ? `${days.value}d` : null,
34
+ hours.value ? `${hours.value}h` : null,
35
+ minutes.value ? `${minutes.value}m` : null,
36
+ duration.value < showSecondsWithin && seconds.value
37
+ ? `${seconds.value}s`
38
+ : null,
39
+ ]
40
+ .filter((s) => !!s)
41
+ .join(' '),
42
+ )
43
+
44
+ return {
45
+ seconds,
46
+ minutes,
47
+ hours,
48
+ days,
49
+ str,
50
+ }
51
+ }
52
+
53
+ export const useTimeAgo = (time: Ref<string | undefined>) => {
54
+ const ago = ref<string>()
55
+ const nowRef = useSeconds()
56
+
57
+ watch(
58
+ nowRef,
59
+ () => {
60
+ if (time.value) {
61
+ ago.value =
62
+ DateTime.fromISO(time.value).toRelative({
63
+ style: 'short',
64
+ locale: 'en',
65
+ }) ?? undefined
66
+ }
67
+ },
68
+ {
69
+ immediate: true,
70
+ },
71
+ )
72
+
73
+ return ago
74
+ }
75
+
76
+ /** @deprecated Use `useTimeAgo` instead. */
77
+ export const useSecondsAgo = (...args: Parameters<typeof useTimeAgo>) => {
78
+ console.warn(
79
+ '[deprecated] useSecondsAgo is deprecated, use useTimeAgo instead.',
80
+ )
81
+ return useTimeAgo(...args)
82
+ }
@@ -0,0 +1,40 @@
1
+ import { ref } from 'vue'
2
+
3
+ export interface ToastAction {
4
+ label: string
5
+ onClick: () => void
6
+ }
7
+
8
+ export type ToastVariant = 'info' | 'success' | 'error'
9
+
10
+ export interface Toast {
11
+ id: string
12
+ title?: string
13
+ description?: string
14
+ variant?: ToastVariant
15
+ action?: ToastAction
16
+ duration?: number
17
+ }
18
+
19
+ const toasts = ref<Toast[]>([])
20
+
21
+ export const useToast = () => {
22
+ const add = (toast: Omit<Toast, 'id'>) => {
23
+ const id = crypto.randomUUID()
24
+ toasts.value.push({ ...toast, id })
25
+ return id
26
+ }
27
+
28
+ const update = (id: string, partial: Partial<Omit<Toast, 'id'>>) => {
29
+ const index = toasts.value.findIndex((t) => t.id === id)
30
+ if (index !== -1) {
31
+ toasts.value[index] = { ...toasts.value[index], ...partial, id }
32
+ }
33
+ }
34
+
35
+ const dismiss = (id: string) => {
36
+ toasts.value = toasts.value.filter((t) => t.id !== id)
37
+ }
38
+
39
+ return { toasts, add, update, dismiss }
40
+ }
@@ -0,0 +1,20 @@
1
+ import type { InjectionKey } from 'vue'
2
+
3
+ export type IconAliases = Record<string, string>
4
+
5
+ export const IconAliasesKey: InjectionKey<IconAliases> = Symbol('IconAliases')
6
+
7
+ export const defaultIconAliases: IconAliases = {
8
+ add: 'lucide:plus',
9
+ check: 'lucide:check',
10
+ close: 'lucide:x',
11
+ 'chevron-down': 'lucide:chevron-down',
12
+ 'chevron-right': 'lucide:chevron-right',
13
+ copy: 'lucide:copy',
14
+ edit: 'lucide:pencil',
15
+ help: 'lucide:circle-question-mark',
16
+ home: 'lucide:house',
17
+ link: 'lucide:link',
18
+ loader: 'lucide:loader-2',
19
+ wallet: 'lucide:wallet',
20
+ }
@@ -0,0 +1,4 @@
1
+ import type { Component, InjectionKey } from 'vue'
2
+
3
+ export const LinkComponentKey: InjectionKey<Component | string> =
4
+ Symbol('LinkComponent')
@@ -0,0 +1,29 @@
1
+ export const formatNumber = (num: number) => num?.toLocaleString('en-US')
2
+
3
+ export const roundAndFormatNumber = (num: number, decimals: number = 2) => {
4
+ const multiplier = Math.pow(10, decimals)
5
+ const rounded = Math.round(num * multiplier) / multiplier
6
+ return formatNumber(rounded === num ? num : rounded)
7
+ }
8
+
9
+ export const asPercentageOf = (num: number = 0, base: number = 1) => {
10
+ return formatNumber(Math.round((num / base) * 100))
11
+ }
12
+
13
+ export function formatUSD(
14
+ value: string | number,
15
+ fractionDigits: number = 0,
16
+ ): string {
17
+ const numberValue = typeof value === 'string' ? parseFloat(value) : value
18
+
19
+ if (isNaN(numberValue)) {
20
+ throw new Error('Invalid number input')
21
+ }
22
+
23
+ return new Intl.NumberFormat('en-US', {
24
+ style: 'currency',
25
+ currency: 'USD',
26
+ minimumFractionDigits: fractionDigits,
27
+ maximumFractionDigits: fractionDigits,
28
+ }).format(numberValue)
29
+ }
@@ -0,0 +1,20 @@
1
+ import { DateTime } from 'luxon'
2
+
3
+ export const delay = (ms: number): Promise<void> =>
4
+ new Promise((resolve) => setTimeout(resolve, ms))
5
+
6
+ export const daysInSeconds = (days: number): number => days * 60 * 60 * 24
7
+
8
+ export const nowInSeconds = (): number => Math.floor(Date.now() / 1000)
9
+
10
+ export const asUTCDate = (date: Date | null) =>
11
+ date
12
+ ? DateTime.utc(
13
+ date.getFullYear(),
14
+ date.getMonth() + 1,
15
+ date.getDate(),
16
+ date.getHours(),
17
+ date.getMinutes(),
18
+ date.getSeconds(),
19
+ )
20
+ : null
@@ -0,0 +1,28 @@
1
+ <template>
2
+ <slot
3
+ :display="display"
4
+ :is-current="isCurrent"
5
+ >
6
+ <span>{{ display }}</span>
7
+ </slot>
8
+ </template>
9
+
10
+ <script setup lang="ts">
11
+ import { computed } from 'vue'
12
+ import type { Address } from 'viem'
13
+ import { useConnection } from '@wagmi/vue'
14
+ import { shortAddress } from '../utils/addresses'
15
+
16
+ const props = defineProps<{
17
+ address?: Address
18
+ }>()
19
+ const address = computed(() => props.address)
20
+
21
+ const { address: currentAddress } = useConnection()
22
+
23
+ const isCurrent = computed<boolean>(
24
+ () => currentAddress.value?.toLowerCase() === address.value?.toLowerCase(),
25
+ )
26
+
27
+ const display = computed<string>(() => shortAddress(address.value!))
28
+ </script>
@@ -0,0 +1,254 @@
1
+ <template>
2
+ <Button
3
+ v-if="showConnect"
4
+ @click="chooseModalOpen = true"
5
+ :class="className"
6
+ >
7
+ <slot>Connect Wallet</slot>
8
+ </Button>
9
+ <slot
10
+ v-else
11
+ name="connected"
12
+ :address="address"
13
+ >
14
+ <EvmAccount :address="address" />
15
+ </slot>
16
+
17
+ <Dialog
18
+ v-if="showConnect"
19
+ title="Connect Wallet"
20
+ v-model:open="chooseModalOpen"
21
+ @closed="onModalClosed"
22
+ >
23
+ <Alert
24
+ v-if="errorMessage"
25
+ type="error"
26
+ >
27
+ {{ errorMessage }}
28
+ </Alert>
29
+ <EvmWalletConnectQR
30
+ v-if="walletConnectUri"
31
+ :uri="walletConnectUri"
32
+ />
33
+ <EvmMetaMaskQR
34
+ v-else-if="metaMaskUri"
35
+ :uri="metaMaskUri"
36
+ />
37
+ <template v-else-if="isConnecting">
38
+ <Loading
39
+ txt="Waiting for wallet confirmation..."
40
+ spinner
41
+ stacked
42
+ />
43
+ </template>
44
+ <div
45
+ v-else
46
+ class="wallet-options"
47
+ >
48
+ <Button
49
+ v-for="connector in shownConnectors"
50
+ :key="connector.uid"
51
+ @click="() => login(connector)"
52
+ class="choose-connector"
53
+ >
54
+ <img
55
+ v-if="ICONS[connector.name]"
56
+ :src="
57
+ connector.icon || `${base}icons/wallets/${ICONS[connector.name]}`
58
+ "
59
+ :alt="connector.name"
60
+ />
61
+ <div
62
+ v-else
63
+ class="default-wallet-icon"
64
+ >
65
+ <Icon type="wallet" />
66
+ </div>
67
+ <span>{{ connector.name }}</span>
68
+ </Button>
69
+ <Button
70
+ to="https://ethereum.org/wallets/"
71
+ target="_blank"
72
+ class="link muted small"
73
+ >
74
+ <Icon type="help" />
75
+ <span>New to wallets?</span>
76
+ </Button>
77
+ </div>
78
+ </Dialog>
79
+ </template>
80
+
81
+ <script setup lang="ts">
82
+ import { ref, computed, watch, onMounted } from 'vue'
83
+ import type { Connector } from '@wagmi/vue'
84
+ import {
85
+ useConnection,
86
+ useConnect,
87
+ useConnectors,
88
+ useChainId,
89
+ } from '@wagmi/vue'
90
+ import Button from '../../base/components/Button.vue'
91
+ import Dialog from '../../base/components/Dialog.vue'
92
+ import Icon from '../../base/components/Icon.vue'
93
+ import Alert from '../../base/components/Alert.vue'
94
+ import Loading from '../../base/components/Loading.vue'
95
+ import EvmAccount from './EvmAccount.vue'
96
+ import EvmWalletConnectQR from './EvmWalletConnectQR.vue'
97
+ import EvmMetaMaskQR from './EvmMetaMaskQR.vue'
98
+ import { useBaseURL } from '../composables/base'
99
+
100
+ const ICONS: Record<string, string> = {
101
+ 'Coinbase Wallet': 'coinbase.svg',
102
+ MetaMask: 'metamask.svg',
103
+ Phantom: 'phantom.svg',
104
+ 'Rabby Wallet': 'rabby.svg',
105
+ Rainbow: 'rainbow.svg',
106
+ WalletConnect: 'walletconnect.svg',
107
+ }
108
+
109
+ const PRIORITY: Record<string, number> = {
110
+ WalletConnect: 20,
111
+ 'Coinbase Wallet': 10,
112
+ }
113
+
114
+ defineProps<{
115
+ className?: string
116
+ }>()
117
+ const emit = defineEmits<{
118
+ connected: [{ address: `0x${string}` | undefined }]
119
+ disconnected: []
120
+ }>()
121
+ const base = useBaseURL()
122
+
123
+ const chainId = useChainId()
124
+ const connectors = useConnectors()
125
+ const { mutateAsync: connectAsync } = useConnect()
126
+ const { address, isConnected } = useConnection()
127
+
128
+ const showConnect = computed(() => !isConnected.value)
129
+ const shownConnectors = computed(() => {
130
+ const unique = Array.from(
131
+ new Map(
132
+ connectors.value?.map((connector) => [connector.name, connector]),
133
+ ).values(),
134
+ )
135
+
136
+ const filtered =
137
+ unique.length > 1 ? unique.filter((c) => c.id !== 'injected') : unique
138
+
139
+ return filtered.sort((a, b) => {
140
+ const priorityA = PRIORITY[a.name] ?? 5
141
+ const priorityB = PRIORITY[b.name] ?? 5
142
+ return priorityA - priorityB
143
+ })
144
+ })
145
+
146
+ const chooseModalOpen = ref(false)
147
+ const errorMessage = ref('')
148
+ const isConnecting = ref(false)
149
+ const walletConnectUri = ref('')
150
+ const metaMaskUri = ref('')
151
+
152
+ const login = async (connector: Connector) => {
153
+ errorMessage.value = ''
154
+ isConnecting.value = true
155
+ walletConnectUri.value = ''
156
+ metaMaskUri.value = ''
157
+
158
+ const handleMessage = (event: { type: string; data?: unknown }) => {
159
+ if (event.type === 'display_uri' && typeof event.data === 'string') {
160
+ if (connector.id === 'walletConnect') {
161
+ walletConnectUri.value = event.data
162
+ } else if (connector.id === 'metaMaskSDK') {
163
+ metaMaskUri.value = event.data
164
+ }
165
+ }
166
+ }
167
+
168
+ if (connector.id === 'walletConnect' || connector.id === 'metaMaskSDK') {
169
+ connector.emitter.on('message', handleMessage)
170
+ }
171
+
172
+ try {
173
+ await connectAsync({ connector, chainId: chainId.value })
174
+
175
+ setTimeout(() => {
176
+ chooseModalOpen.value = false
177
+ isConnecting.value = false
178
+ walletConnectUri.value = ''
179
+ metaMaskUri.value = ''
180
+ }, 100)
181
+ } catch (error: unknown) {
182
+ isConnecting.value = false
183
+ walletConnectUri.value = ''
184
+ metaMaskUri.value = ''
185
+
186
+ const errorMsg = error instanceof Error ? error.message : ''
187
+ if (
188
+ errorMsg.includes('User rejected') ||
189
+ errorMsg.includes('rejected') ||
190
+ errorMsg.includes('denied')
191
+ ) {
192
+ errorMessage.value = 'Connection cancelled. Please try again.'
193
+ } else {
194
+ errorMessage.value = 'Failed to connect. Please try again.'
195
+ }
196
+ console.error('Wallet connection error:', error)
197
+ } finally {
198
+ if (connector.id === 'walletConnect' || connector.id === 'metaMaskSDK') {
199
+ connector.emitter.off('message', handleMessage)
200
+ }
201
+ }
202
+ }
203
+
204
+ const onModalClosed = () => {
205
+ errorMessage.value = ''
206
+ isConnecting.value = false
207
+ walletConnectUri.value = ''
208
+ metaMaskUri.value = ''
209
+ }
210
+
211
+ const check = () =>
212
+ isConnected.value
213
+ ? emit('connected', { address: address.value })
214
+ : emit('disconnected')
215
+ watch(isConnected, () => check())
216
+ onMounted(() => check())
217
+ </script>
218
+
219
+ <style scoped>
220
+ .wallet-options {
221
+ display: grid;
222
+ gap: var(--spacer);
223
+
224
+ button.choose-connector {
225
+ width: 100%;
226
+ inline-size: auto;
227
+ justify-content: flex-start;
228
+
229
+ img,
230
+ .default-wallet-icon {
231
+ margin: -1rem 0 -1rem -0.6rem;
232
+ width: var(--size-5);
233
+ height: var(--size-5);
234
+ }
235
+
236
+ .default-wallet-icon {
237
+ display: flex;
238
+ align-items: center;
239
+ justify-content: center;
240
+ background: var(--gray-z-2);
241
+ }
242
+
243
+ span:last-child {
244
+ border-left: var(--border);
245
+ padding-left: var(--spacer-sm);
246
+ }
247
+ }
248
+ }
249
+
250
+ .link.muted {
251
+ justify-self: center;
252
+ font-size: var(--font-xs);
253
+ }
254
+ </style>
@@ -0,0 +1,116 @@
1
+ <template>
2
+ <p>
3
+ <slot name="instruction">Scan the code in your wallet application</slot>
4
+ </p>
5
+ <div class="qr-frame">
6
+ <canvas ref="qrCanvas"></canvas>
7
+ </div>
8
+ <p class="uri-label">Or copy the connection URI:</p>
9
+ <div class="uri-display">
10
+ <code>{{ uri }}</code>
11
+ <Button
12
+ @click="copyUri"
13
+ class="copy-button"
14
+ :class="{ copied: isCopied }"
15
+ >
16
+ <Icon :type="isCopied ? 'check' : 'copy'" />
17
+ </Button>
18
+ </div>
19
+ </template>
20
+
21
+ <script setup lang="ts">
22
+ import { ref, watch, onMounted } from 'vue'
23
+ import QRCode from 'qrcode'
24
+ import { useClipboard } from '@vueuse/core'
25
+ import Button from '../../base/components/Button.vue'
26
+ import Icon from '../../base/components/Icon.vue'
27
+
28
+ const props = defineProps<{
29
+ uri: string
30
+ }>()
31
+
32
+ const qrCanvas = ref<HTMLCanvasElement | null>(null)
33
+ const { copy, copied: isCopied } = useClipboard()
34
+
35
+ const generateQR = async () => {
36
+ if (!qrCanvas.value || !props.uri) return
37
+
38
+ try {
39
+ await QRCode.toCanvas(qrCanvas.value, props.uri, {
40
+ width: 300,
41
+ margin: 2,
42
+ color: {
43
+ dark: '#000000',
44
+ light: '#FFFFFF',
45
+ },
46
+ })
47
+ } catch (error) {
48
+ console.error('Failed to generate QR code:', error)
49
+ }
50
+ }
51
+
52
+ const copyUri = () => copy(props.uri)
53
+
54
+ watch(() => props.uri, generateQR, { immediate: true })
55
+
56
+ onMounted(() => {
57
+ generateQR()
58
+ })
59
+ </script>
60
+
61
+ <style scoped>
62
+ p {
63
+ text-align: center;
64
+ @mixin ui-font;
65
+ color: var(--muted);
66
+ font-size: var(--font-sm);
67
+ }
68
+
69
+ .qr-frame {
70
+ background: white;
71
+ padding: var(--spacer-sm);
72
+ max-width: 15rem;
73
+ max-height: 15rem;
74
+ border: var(--border);
75
+ border-radius: var(--border-radius);
76
+ margin: 0 auto;
77
+
78
+ canvas {
79
+ width: 100% !important;
80
+ height: 100% !important;
81
+ }
82
+ }
83
+
84
+ .uri-display {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: var(--spacer-xs);
88
+ background: var(--color-bg-secondary);
89
+ border: var(--border);
90
+ border-radius: var(--border-radius-sm);
91
+ overflow: hidden;
92
+ height: min-content;
93
+ padding: 0;
94
+
95
+ code {
96
+ flex: 1;
97
+ font-size: var(--font-xs);
98
+ font-family: monospace;
99
+ white-space: nowrap;
100
+ overflow: hidden;
101
+ padding: 0 var(--spacer-sm);
102
+ color: var(--muted);
103
+ }
104
+
105
+ .copy-button {
106
+ flex-shrink: 0;
107
+ padding: var(--spacer-xs);
108
+ min-width: auto;
109
+ margin: -1px;
110
+
111
+ &.copied {
112
+ color: var(--color-success);
113
+ }
114
+ }
115
+ }
116
+ </style>
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <EvmConnectorQR :uri="uri">
3
+ <template #instruction>
4
+ Scan the code in your MetaMask mobile app
5
+ </template>
6
+ </EvmConnectorQR>
7
+ </template>
8
+
9
+ <script setup lang="ts">
10
+ import EvmConnectorQR from './EvmConnectorQR.vue'
11
+
12
+ defineProps<{
13
+ uri: string
14
+ }>()
15
+ </script>