@akta/algomd-rn 0.0.1-canary
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 +46 -0
- package/src/components/ASA.tsx +221 -0
- package/src/components/ASASearch.tsx +79 -0
- package/src/components/Account.tsx +150 -0
- package/src/components/AccountSearch.tsx +75 -0
- package/src/components/AuctionListing.tsx +220 -0
- package/src/components/NFDProfile.tsx +173 -0
- package/src/components/NFDSearch.tsx +81 -0
- package/src/components/NFTListing.tsx +181 -0
- package/src/components/NFTSearch.tsx +68 -0
- package/src/components/Poll.tsx +234 -0
- package/src/components/PollSearch.tsx +79 -0
- package/src/components/RaffleListing.tsx +204 -0
- package/src/components/SelfFetching.tsx +358 -0
- package/src/components/TradeOffer.tsx +245 -0
- package/src/components/TradeSearch.tsx +103 -0
- package/src/components/TransactionDetails.tsx +242 -0
- package/src/components/TransactionSearch.tsx +102 -0
- package/src/components/index.ts +30 -0
- package/src/hooks/index.ts +12 -0
- package/src/hooks/useAlgomdData.ts +445 -0
- package/src/hooks/useAlgorandClient.ts +39 -0
- package/src/index.ts +83 -0
- package/src/provider/AlgomdProvider.tsx +45 -0
- package/src/provider/context.ts +18 -0
- package/src/provider/imageResolver.ts +12 -0
- package/src/provider/index.ts +5 -0
- package/src/provider/networks.ts +23 -0
- package/src/provider/types.ts +17 -0
- package/src/types/algorand.ts +205 -0
- package/src/types/index.ts +1 -0
- package/src/ui/CopyButton.tsx +40 -0
- package/src/ui/DataStates.tsx +41 -0
- package/src/ui/ProgressBar.tsx +42 -0
- package/src/ui/SearchSheet.tsx +134 -0
- package/src/ui/SizeContainer.tsx +25 -0
- package/src/ui/StatusBadge.tsx +51 -0
- package/src/ui/index.ts +6 -0
- package/src/utils/format.ts +70 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/search.ts +65 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Core Algorand Types for React Native
|
|
2
|
+
export interface AlgorandAccount {
|
|
3
|
+
id: string;
|
|
4
|
+
address: string;
|
|
5
|
+
balance: number;
|
|
6
|
+
assets: ASA[];
|
|
7
|
+
apps: Application[];
|
|
8
|
+
createdAt: Date;
|
|
9
|
+
isOnline: boolean;
|
|
10
|
+
round: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface NFDProfile {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
address: string;
|
|
17
|
+
avatar?: string;
|
|
18
|
+
bio?: string;
|
|
19
|
+
properties: Record<string, string>;
|
|
20
|
+
verified: boolean;
|
|
21
|
+
createdAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ASA {
|
|
25
|
+
id: number;
|
|
26
|
+
name: string;
|
|
27
|
+
unitName: string;
|
|
28
|
+
total: number;
|
|
29
|
+
decimals: number;
|
|
30
|
+
defaultFrozen: boolean;
|
|
31
|
+
url?: string;
|
|
32
|
+
metadataHash?: string;
|
|
33
|
+
manager?: string;
|
|
34
|
+
reserve?: string;
|
|
35
|
+
freeze?: string;
|
|
36
|
+
clawback?: string;
|
|
37
|
+
creator: string;
|
|
38
|
+
createdAt: Date;
|
|
39
|
+
price?: number;
|
|
40
|
+
verified: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface Application {
|
|
44
|
+
id: number;
|
|
45
|
+
creator: string;
|
|
46
|
+
globalState: Record<string, unknown>;
|
|
47
|
+
localState: Record<string, unknown>;
|
|
48
|
+
params: {
|
|
49
|
+
globalNumUint: number;
|
|
50
|
+
globalNumByteSlice: number;
|
|
51
|
+
localNumUint: number;
|
|
52
|
+
localNumByteSlice: number;
|
|
53
|
+
};
|
|
54
|
+
createdAt: Date;
|
|
55
|
+
name?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TransactionDetails {
|
|
60
|
+
id: string;
|
|
61
|
+
type:
|
|
62
|
+
| 'payment'
|
|
63
|
+
| 'asset-transfer'
|
|
64
|
+
| 'application-call'
|
|
65
|
+
| 'asset-config'
|
|
66
|
+
| 'key-registration'
|
|
67
|
+
| 'asset-freeze';
|
|
68
|
+
from: string;
|
|
69
|
+
to?: string;
|
|
70
|
+
amount?: number;
|
|
71
|
+
asset?: ASA;
|
|
72
|
+
application?: Application;
|
|
73
|
+
note?: string;
|
|
74
|
+
fee: number;
|
|
75
|
+
round: number;
|
|
76
|
+
timestamp: Date;
|
|
77
|
+
confirmed: boolean;
|
|
78
|
+
signature: string;
|
|
79
|
+
context?: {
|
|
80
|
+
type: 'nft-purchase' | 'auction-won' | 'raffle-entry' | 'trade-offer' | 'vote';
|
|
81
|
+
metadata: Record<string, unknown>;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Social and Trading Types
|
|
86
|
+
export interface NFTListing {
|
|
87
|
+
id: string;
|
|
88
|
+
nft: ASA;
|
|
89
|
+
price: number;
|
|
90
|
+
priceAsset: ASA;
|
|
91
|
+
currency: string;
|
|
92
|
+
seller: string;
|
|
93
|
+
collection?: string;
|
|
94
|
+
authenticityBadge: boolean;
|
|
95
|
+
quantity: number;
|
|
96
|
+
reservedFor?: string;
|
|
97
|
+
gating?: GatingInfo;
|
|
98
|
+
views: number;
|
|
99
|
+
favorites: number;
|
|
100
|
+
createdAt: Date;
|
|
101
|
+
listedAt: Date;
|
|
102
|
+
expiresAt?: Date;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RaffleListing {
|
|
106
|
+
id: string;
|
|
107
|
+
title: string;
|
|
108
|
+
description: string;
|
|
109
|
+
pricePerEntry: number;
|
|
110
|
+
entryAsset: ASA;
|
|
111
|
+
startTime: Date;
|
|
112
|
+
endTime: Date;
|
|
113
|
+
prizes: ASA[];
|
|
114
|
+
entryCount: number;
|
|
115
|
+
ticketCount: number;
|
|
116
|
+
gating?: GatingInfo;
|
|
117
|
+
creator: string;
|
|
118
|
+
status: 'upcoming' | 'active' | 'ended' | 'claimed';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface AuctionListing {
|
|
122
|
+
id: string;
|
|
123
|
+
title: string;
|
|
124
|
+
description: string;
|
|
125
|
+
bidAsset: ASA;
|
|
126
|
+
currentHighestBid: number;
|
|
127
|
+
minimumNextBid: number;
|
|
128
|
+
startTime: Date;
|
|
129
|
+
endTime: Date;
|
|
130
|
+
prizes: ASA[];
|
|
131
|
+
bidFeePercentage?: number;
|
|
132
|
+
currentBidFeePool: number;
|
|
133
|
+
bidCount: number;
|
|
134
|
+
timeExtended: boolean;
|
|
135
|
+
creator: string;
|
|
136
|
+
status: 'upcoming' | 'active' | 'ended' | 'claimed';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface TradeOffer {
|
|
140
|
+
id: string;
|
|
141
|
+
creator: string;
|
|
142
|
+
recipients: string[];
|
|
143
|
+
offering: (ASA | AlgorandAccount)[];
|
|
144
|
+
requesting: (ASA | AlgorandAccount)[];
|
|
145
|
+
message?: string;
|
|
146
|
+
expiresAt: Date;
|
|
147
|
+
status: 'pending' | 'accepted' | 'rejected' | 'expired';
|
|
148
|
+
createdAt: Date;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface Poll {
|
|
152
|
+
id: string;
|
|
153
|
+
question: string;
|
|
154
|
+
options: PollOption[];
|
|
155
|
+
creator: string;
|
|
156
|
+
createdAt: Date;
|
|
157
|
+
expiresAt?: Date;
|
|
158
|
+
totalVotes: number;
|
|
159
|
+
status: 'active' | 'ended';
|
|
160
|
+
gating?: GatingInfo;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface PollOption {
|
|
164
|
+
id: string;
|
|
165
|
+
text: string;
|
|
166
|
+
asset?: ASA;
|
|
167
|
+
collection?: string;
|
|
168
|
+
votes: number;
|
|
169
|
+
votingPower: number;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface GatingInfo {
|
|
173
|
+
type: 'asset-holding' | 'nfd-verified' | 'application-optin' | 'custom' | 'token' | 'nft';
|
|
174
|
+
requirements: {
|
|
175
|
+
assets?: { assetId: number; minimumBalance: number }[];
|
|
176
|
+
nfdVerified?: boolean;
|
|
177
|
+
applications?: number[];
|
|
178
|
+
custom?: Record<string, unknown>;
|
|
179
|
+
};
|
|
180
|
+
requirement?: number;
|
|
181
|
+
asset?: string;
|
|
182
|
+
collection?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Search Types
|
|
186
|
+
export interface SearchResult<T> {
|
|
187
|
+
item: T;
|
|
188
|
+
score: number;
|
|
189
|
+
matches: string[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type SearchableEntity =
|
|
193
|
+
| AlgorandAccount
|
|
194
|
+
| NFDProfile
|
|
195
|
+
| ASA
|
|
196
|
+
| Application
|
|
197
|
+
| TransactionDetails
|
|
198
|
+
| NFTListing
|
|
199
|
+
| RaffleListing
|
|
200
|
+
| AuctionListing
|
|
201
|
+
| TradeOffer
|
|
202
|
+
| Poll;
|
|
203
|
+
|
|
204
|
+
// Component shared prop types
|
|
205
|
+
export type ComponentSize = 'sm' | 'md' | 'lg' | 'full' | 'fullscreen';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './algorand';
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { Pressable } from 'react-native';
|
|
3
|
+
import * as Clipboard from 'expo-clipboard';
|
|
4
|
+
import * as Haptics from 'expo-haptics';
|
|
5
|
+
import Svg, { Path } from 'react-native-svg';
|
|
6
|
+
|
|
7
|
+
interface CopyButtonProps {
|
|
8
|
+
value: string;
|
|
9
|
+
size?: number;
|
|
10
|
+
color?: string;
|
|
11
|
+
className?: string;
|
|
12
|
+
onCopied?: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function CopyIcon({ size = 16, color = '#a1a1aa' }: { size?: number; color?: string }) {
|
|
16
|
+
return (
|
|
17
|
+
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
18
|
+
<Path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
|
19
|
+
<Path d="M15 2H9a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1Z" />
|
|
20
|
+
</Svg>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function CopyButton({ value, size = 16, color = '#a1a1aa', className, onCopied }: CopyButtonProps) {
|
|
25
|
+
const handleCopy = useCallback(async () => {
|
|
26
|
+
await Clipboard.setStringAsync(value);
|
|
27
|
+
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
28
|
+
onCopied?.();
|
|
29
|
+
}, [value, onCopied]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Pressable
|
|
33
|
+
onPress={handleCopy}
|
|
34
|
+
className={`p-1.5 rounded-lg ${className ?? ''}`}
|
|
35
|
+
hitSlop={8}
|
|
36
|
+
>
|
|
37
|
+
<CopyIcon size={size} color={color} />
|
|
38
|
+
</Pressable>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Text, ActivityIndicator } from 'react-native';
|
|
3
|
+
|
|
4
|
+
interface LoadingSkeletonProps {
|
|
5
|
+
name: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function LoadingSkeleton({ name }: LoadingSkeletonProps) {
|
|
9
|
+
return (
|
|
10
|
+
<View
|
|
11
|
+
className="rounded-xl items-center justify-center bg-zinc-900/80"
|
|
12
|
+
style={{ padding: 20, minHeight: 80, borderWidth: 1, borderColor: '#27272a' }}
|
|
13
|
+
>
|
|
14
|
+
<ActivityIndicator size="small" color="#a1a1aa" />
|
|
15
|
+
<Text className="mt-2 text-xs text-zinc-500">
|
|
16
|
+
Loading {name}...
|
|
17
|
+
</Text>
|
|
18
|
+
</View>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ErrorStateProps {
|
|
23
|
+
name: string;
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ErrorState({ name, message }: ErrorStateProps) {
|
|
28
|
+
return (
|
|
29
|
+
<View
|
|
30
|
+
className="rounded-xl bg-zinc-900/80"
|
|
31
|
+
style={{ padding: 16, minHeight: 60, borderWidth: 1, borderColor: 'rgba(239,68,68,0.25)' }}
|
|
32
|
+
>
|
|
33
|
+
<Text className="text-xs font-semibold text-red-400">
|
|
34
|
+
Failed to load {name}
|
|
35
|
+
</Text>
|
|
36
|
+
<Text className="text-xs text-zinc-500 mt-1">
|
|
37
|
+
{message}
|
|
38
|
+
</Text>
|
|
39
|
+
</View>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
withTiming,
|
|
6
|
+
useDerivedValue,
|
|
7
|
+
} from 'react-native-reanimated';
|
|
8
|
+
|
|
9
|
+
interface ProgressBarProps {
|
|
10
|
+
/** A value between 0 and 100. */
|
|
11
|
+
percentage: number;
|
|
12
|
+
/** Height of the bar in Tailwind. Defaults to h-2. */
|
|
13
|
+
heightClass?: string;
|
|
14
|
+
/** Track background class. */
|
|
15
|
+
trackClass?: string;
|
|
16
|
+
/** Fill background class. */
|
|
17
|
+
fillClass?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ProgressBar({
|
|
22
|
+
percentage,
|
|
23
|
+
heightClass = 'h-2',
|
|
24
|
+
trackClass = 'bg-zinc-700',
|
|
25
|
+
fillClass = 'bg-purple-500',
|
|
26
|
+
className,
|
|
27
|
+
}: ProgressBarProps) {
|
|
28
|
+
const clampedPct = useDerivedValue(() => Math.max(0, Math.min(100, percentage)));
|
|
29
|
+
|
|
30
|
+
const fillStyle = useAnimatedStyle(() => ({
|
|
31
|
+
width: `${withTiming(clampedPct.value, { duration: 500 })}%`,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<View className={`w-full rounded-full overflow-hidden ${trackClass} ${heightClass} ${className ?? ''}`}>
|
|
36
|
+
<Animated.View
|
|
37
|
+
className={`${heightClass} rounded-full ${fillClass}`}
|
|
38
|
+
style={fillStyle}
|
|
39
|
+
/>
|
|
40
|
+
</View>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { View, Text, TextInput, Pressable } from 'react-native';
|
|
3
|
+
import { FlashList } from '@shopify/flash-list';
|
|
4
|
+
import BottomSheet, { BottomSheetBackdrop, BottomSheetView } from '@gorhom/bottom-sheet';
|
|
5
|
+
import Svg, { Path } from 'react-native-svg';
|
|
6
|
+
import type { SearchableEntity, SearchResult } from '../types/algorand';
|
|
7
|
+
import { searchEntities } from '../utils/search';
|
|
8
|
+
|
|
9
|
+
function SearchIcon({ size = 16, color = '#a1a1aa' }: { size?: number; color?: string }) {
|
|
10
|
+
return (
|
|
11
|
+
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
12
|
+
<Path d="M11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16Z" />
|
|
13
|
+
<Path d="m21 21-4.35-4.35" />
|
|
14
|
+
</Svg>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SearchSheetProps<T extends SearchableEntity> {
|
|
19
|
+
data: T[];
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
onSelect?: (item: T) => void;
|
|
22
|
+
renderItem: (item: T) => React.ReactElement;
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function SearchSheet<T extends SearchableEntity>({
|
|
27
|
+
data,
|
|
28
|
+
placeholder = 'Search...',
|
|
29
|
+
onSelect,
|
|
30
|
+
renderItem,
|
|
31
|
+
className,
|
|
32
|
+
}: SearchSheetProps<T>) {
|
|
33
|
+
const sheetRef = useRef<BottomSheet>(null);
|
|
34
|
+
const [query, setQuery] = useState('');
|
|
35
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
36
|
+
const snapPoints = useMemo(() => ['50%', '80%'], []);
|
|
37
|
+
|
|
38
|
+
const results = useMemo(() => {
|
|
39
|
+
if (!query.trim()) {
|
|
40
|
+
return data.slice(0, 10).map((item) => ({ item, score: 1, matches: [] as string[] }));
|
|
41
|
+
}
|
|
42
|
+
return searchEntities(data, query, 10);
|
|
43
|
+
}, [data, query]);
|
|
44
|
+
|
|
45
|
+
const handleOpen = useCallback(() => {
|
|
46
|
+
setIsOpen(true);
|
|
47
|
+
sheetRef.current?.snapToIndex(0);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const handleSelect = useCallback(
|
|
51
|
+
(item: T) => {
|
|
52
|
+
onSelect?.(item);
|
|
53
|
+
setQuery('');
|
|
54
|
+
sheetRef.current?.close();
|
|
55
|
+
},
|
|
56
|
+
[onSelect],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const renderBackdrop = useCallback(
|
|
60
|
+
(props: React.ComponentProps<typeof BottomSheetBackdrop>) => (
|
|
61
|
+
<BottomSheetBackdrop {...props} disappearsOnIndex={-1} appearsOnIndex={0} />
|
|
62
|
+
),
|
|
63
|
+
[],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const renderResultItem = useCallback(
|
|
67
|
+
({ item }: { item: SearchResult<T> }) => (
|
|
68
|
+
<Pressable
|
|
69
|
+
onPress={() => handleSelect(item.item)}
|
|
70
|
+
className="px-4 py-3 border-b border-zinc-800"
|
|
71
|
+
>
|
|
72
|
+
{renderItem(item.item)}
|
|
73
|
+
</Pressable>
|
|
74
|
+
),
|
|
75
|
+
[handleSelect, renderItem],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<View className={className}>
|
|
80
|
+
{/* Search Trigger Button */}
|
|
81
|
+
<Pressable
|
|
82
|
+
onPress={handleOpen}
|
|
83
|
+
className="flex-row items-center gap-2 px-3 py-2.5 rounded-lg border border-zinc-700 bg-zinc-900"
|
|
84
|
+
>
|
|
85
|
+
<SearchIcon size={16} color="#71717a" />
|
|
86
|
+
<Text className="flex-1 text-sm text-zinc-500">{placeholder}</Text>
|
|
87
|
+
</Pressable>
|
|
88
|
+
|
|
89
|
+
{/* Bottom Sheet */}
|
|
90
|
+
<BottomSheet
|
|
91
|
+
ref={sheetRef}
|
|
92
|
+
index={-1}
|
|
93
|
+
snapPoints={snapPoints}
|
|
94
|
+
enablePanDownToClose
|
|
95
|
+
backdropComponent={renderBackdrop}
|
|
96
|
+
backgroundStyle={{ backgroundColor: '#18181b' }}
|
|
97
|
+
handleIndicatorStyle={{ backgroundColor: '#52525b' }}
|
|
98
|
+
onChange={(index) => {
|
|
99
|
+
if (index === -1) setIsOpen(false);
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<BottomSheetView className="flex-1 px-4 pt-2">
|
|
103
|
+
{/* Search Input */}
|
|
104
|
+
<View className="flex-row items-center gap-2 px-3 py-2 mb-3 rounded-lg border border-zinc-700 bg-zinc-800">
|
|
105
|
+
<SearchIcon size={16} color="#71717a" />
|
|
106
|
+
<TextInput
|
|
107
|
+
value={query}
|
|
108
|
+
onChangeText={setQuery}
|
|
109
|
+
placeholder={placeholder}
|
|
110
|
+
placeholderTextColor="#71717a"
|
|
111
|
+
className="flex-1 text-sm text-white p-0"
|
|
112
|
+
autoFocus={isOpen}
|
|
113
|
+
/>
|
|
114
|
+
</View>
|
|
115
|
+
|
|
116
|
+
{/* Results */}
|
|
117
|
+
<FlashList
|
|
118
|
+
data={results}
|
|
119
|
+
renderItem={renderResultItem}
|
|
120
|
+
estimatedItemSize={56}
|
|
121
|
+
keyExtractor={(item, index) => `search-${index}`}
|
|
122
|
+
ListEmptyComponent={
|
|
123
|
+
<View className="py-8 items-center">
|
|
124
|
+
<Text className="text-sm text-zinc-500">
|
|
125
|
+
{query.trim() ? 'No results found.' : 'No items available.'}
|
|
126
|
+
</Text>
|
|
127
|
+
</View>
|
|
128
|
+
}
|
|
129
|
+
/>
|
|
130
|
+
</BottomSheetView>
|
|
131
|
+
</BottomSheet>
|
|
132
|
+
</View>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import type { ComponentSize } from '../types/algorand';
|
|
4
|
+
|
|
5
|
+
const SIZE_MAP: Record<ComponentSize, string> = {
|
|
6
|
+
sm: 'w-full max-w-xs',
|
|
7
|
+
md: 'w-full max-w-sm',
|
|
8
|
+
lg: 'w-full max-w-lg',
|
|
9
|
+
full: 'w-full',
|
|
10
|
+
fullscreen: 'w-full',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
interface SizeContainerProps {
|
|
14
|
+
size: ComponentSize;
|
|
15
|
+
className?: string;
|
|
16
|
+
children: React.ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function SizeContainer({ size, className, children }: SizeContainerProps) {
|
|
20
|
+
return (
|
|
21
|
+
<View className={`${SIZE_MAP[size]} ${className ?? ''}`}>
|
|
22
|
+
{children}
|
|
23
|
+
</View>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Text } from 'react-native';
|
|
3
|
+
|
|
4
|
+
type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'primary';
|
|
5
|
+
|
|
6
|
+
const VARIANT_CLASSES: Record<BadgeVariant, { container: string; text: string }> = {
|
|
7
|
+
success: {
|
|
8
|
+
container: 'bg-green-500/20',
|
|
9
|
+
text: 'text-green-400',
|
|
10
|
+
},
|
|
11
|
+
warning: {
|
|
12
|
+
container: 'bg-yellow-500/20',
|
|
13
|
+
text: 'text-yellow-400',
|
|
14
|
+
},
|
|
15
|
+
error: {
|
|
16
|
+
container: 'bg-red-500/20',
|
|
17
|
+
text: 'text-red-400',
|
|
18
|
+
},
|
|
19
|
+
info: {
|
|
20
|
+
container: 'bg-blue-500/20',
|
|
21
|
+
text: 'text-blue-400',
|
|
22
|
+
},
|
|
23
|
+
neutral: {
|
|
24
|
+
container: 'bg-zinc-500/20',
|
|
25
|
+
text: 'text-zinc-400',
|
|
26
|
+
},
|
|
27
|
+
primary: {
|
|
28
|
+
container: 'bg-purple-500/20',
|
|
29
|
+
text: 'text-purple-400',
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface StatusBadgeProps {
|
|
34
|
+
label: string;
|
|
35
|
+
variant?: BadgeVariant;
|
|
36
|
+
icon?: React.ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function StatusBadge({ label, variant = 'neutral', icon, className }: StatusBadgeProps) {
|
|
41
|
+
const styles = VARIANT_CLASSES[variant];
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<View
|
|
45
|
+
className={`flex-row items-center gap-1 px-2 py-0.5 rounded-full ${styles.container} ${className ?? ''}`}
|
|
46
|
+
>
|
|
47
|
+
{icon}
|
|
48
|
+
<Text className={`text-xs font-medium ${styles.text}`}>{label}</Text>
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { SizeContainer } from './SizeContainer';
|
|
2
|
+
export { CopyButton } from './CopyButton';
|
|
3
|
+
export { StatusBadge } from './StatusBadge';
|
|
4
|
+
export { ProgressBar } from './ProgressBar';
|
|
5
|
+
export { SearchSheet } from './SearchSheet';
|
|
6
|
+
export { LoadingSkeleton, ErrorState } from './DataStates';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format an Algorand address for display (truncated).
|
|
3
|
+
*/
|
|
4
|
+
export function formatAddress(address: string, chars = 5): string {
|
|
5
|
+
if (address.length <= chars * 2) return address;
|
|
6
|
+
return `${address.slice(0, chars)}...${address.slice(-chars)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format a number with locale-aware separators.
|
|
11
|
+
*/
|
|
12
|
+
export function formatNumber(num: number, decimals = 2): string {
|
|
13
|
+
return new Intl.NumberFormat('en-US', {
|
|
14
|
+
minimumFractionDigits: decimals,
|
|
15
|
+
maximumFractionDigits: decimals,
|
|
16
|
+
}).format(num);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Format a currency value with its unit.
|
|
21
|
+
*/
|
|
22
|
+
export function formatCurrency(amount: number, currency = 'ALGO'): string {
|
|
23
|
+
return `${formatNumber(amount)} ${currency}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format a date into a readable string.
|
|
28
|
+
*/
|
|
29
|
+
export function formatDate(date: Date): string {
|
|
30
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
31
|
+
year: 'numeric',
|
|
32
|
+
month: 'short',
|
|
33
|
+
day: 'numeric',
|
|
34
|
+
hour: '2-digit',
|
|
35
|
+
minute: '2-digit',
|
|
36
|
+
}).format(date);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format a date into a human-readable relative time string.
|
|
41
|
+
*/
|
|
42
|
+
export function formatRelativeTime(date: Date): string {
|
|
43
|
+
const now = new Date();
|
|
44
|
+
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
45
|
+
|
|
46
|
+
if (diffInSeconds < 0) {
|
|
47
|
+
// Future date
|
|
48
|
+
const absDiff = Math.abs(diffInSeconds);
|
|
49
|
+
if (absDiff < 60) return 'in a moment';
|
|
50
|
+
if (absDiff < 3600) return `in ${Math.floor(absDiff / 60)}m`;
|
|
51
|
+
if (absDiff < 86400) return `in ${Math.floor(absDiff / 3600)}h`;
|
|
52
|
+
if (absDiff < 2592000) return `in ${Math.floor(absDiff / 86400)}d`;
|
|
53
|
+
return formatDate(date);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (diffInSeconds < 60) return 'just now';
|
|
57
|
+
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
|
58
|
+
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
|
59
|
+
if (diffInSeconds < 2592000) return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
|
60
|
+
|
|
61
|
+
return formatDate(date);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Format an asset amount considering its decimals.
|
|
66
|
+
*/
|
|
67
|
+
export function formatAssetAmount(amount: number, decimals: number): string {
|
|
68
|
+
const divisor = Math.pow(10, decimals);
|
|
69
|
+
return formatNumber(amount / divisor, Math.min(decimals, 6));
|
|
70
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SearchResult,
|
|
3
|
+
SearchableEntity,
|
|
4
|
+
AlgorandAccount,
|
|
5
|
+
NFDProfile,
|
|
6
|
+
ASA,
|
|
7
|
+
Application,
|
|
8
|
+
} from '../types/algorand';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Simple fuzzy search across Algorand entity collections.
|
|
12
|
+
*/
|
|
13
|
+
export function searchEntities<T extends SearchableEntity>(
|
|
14
|
+
entities: T[],
|
|
15
|
+
query: string,
|
|
16
|
+
maxResults = 10,
|
|
17
|
+
): SearchResult<T>[] {
|
|
18
|
+
if (!query.trim()) return [];
|
|
19
|
+
|
|
20
|
+
const queryLower = query.toLowerCase();
|
|
21
|
+
const results: SearchResult<T>[] = [];
|
|
22
|
+
|
|
23
|
+
for (const entity of entities) {
|
|
24
|
+
const matches: string[] = [];
|
|
25
|
+
let score = 0;
|
|
26
|
+
|
|
27
|
+
if ('address' in entity) {
|
|
28
|
+
const account = entity as unknown as AlgorandAccount;
|
|
29
|
+
if (account.address.toLowerCase().includes(queryLower)) {
|
|
30
|
+
matches.push('address');
|
|
31
|
+
score += account.address.toLowerCase().startsWith(queryLower) ? 10 : 5;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if ('name' in entity) {
|
|
36
|
+
const named = entity as unknown as NFDProfile | ASA;
|
|
37
|
+
if (named.name.toLowerCase().includes(queryLower)) {
|
|
38
|
+
matches.push('name');
|
|
39
|
+
score += named.name.toLowerCase().startsWith(queryLower) ? 10 : 5;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if ('unitName' in entity) {
|
|
44
|
+
const asa = entity as unknown as ASA;
|
|
45
|
+
if (asa.unitName.toLowerCase().includes(queryLower)) {
|
|
46
|
+
matches.push('unitName');
|
|
47
|
+
score += asa.unitName.toLowerCase().startsWith(queryLower) ? 8 : 4;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if ('description' in entity && (entity as unknown as Application).description) {
|
|
52
|
+
const app = entity as unknown as Application;
|
|
53
|
+
if (app.description && app.description.toLowerCase().includes(queryLower)) {
|
|
54
|
+
matches.push('description');
|
|
55
|
+
score += 2;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (score > 0) {
|
|
60
|
+
results.push({ item: entity, score, matches });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return results.sort((a, b) => b.score - a.score).slice(0, maxResults);
|
|
65
|
+
}
|