@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.
Files changed (41) hide show
  1. package/package.json +46 -0
  2. package/src/components/ASA.tsx +221 -0
  3. package/src/components/ASASearch.tsx +79 -0
  4. package/src/components/Account.tsx +150 -0
  5. package/src/components/AccountSearch.tsx +75 -0
  6. package/src/components/AuctionListing.tsx +220 -0
  7. package/src/components/NFDProfile.tsx +173 -0
  8. package/src/components/NFDSearch.tsx +81 -0
  9. package/src/components/NFTListing.tsx +181 -0
  10. package/src/components/NFTSearch.tsx +68 -0
  11. package/src/components/Poll.tsx +234 -0
  12. package/src/components/PollSearch.tsx +79 -0
  13. package/src/components/RaffleListing.tsx +204 -0
  14. package/src/components/SelfFetching.tsx +358 -0
  15. package/src/components/TradeOffer.tsx +245 -0
  16. package/src/components/TradeSearch.tsx +103 -0
  17. package/src/components/TransactionDetails.tsx +242 -0
  18. package/src/components/TransactionSearch.tsx +102 -0
  19. package/src/components/index.ts +30 -0
  20. package/src/hooks/index.ts +12 -0
  21. package/src/hooks/useAlgomdData.ts +445 -0
  22. package/src/hooks/useAlgorandClient.ts +39 -0
  23. package/src/index.ts +83 -0
  24. package/src/provider/AlgomdProvider.tsx +45 -0
  25. package/src/provider/context.ts +18 -0
  26. package/src/provider/imageResolver.ts +12 -0
  27. package/src/provider/index.ts +5 -0
  28. package/src/provider/networks.ts +23 -0
  29. package/src/provider/types.ts +17 -0
  30. package/src/types/algorand.ts +205 -0
  31. package/src/types/index.ts +1 -0
  32. package/src/ui/CopyButton.tsx +40 -0
  33. package/src/ui/DataStates.tsx +41 -0
  34. package/src/ui/ProgressBar.tsx +42 -0
  35. package/src/ui/SearchSheet.tsx +134 -0
  36. package/src/ui/SizeContainer.tsx +25 -0
  37. package/src/ui/StatusBadge.tsx +51 -0
  38. package/src/ui/index.ts +6 -0
  39. package/src/utils/format.ts +70 -0
  40. package/src/utils/index.ts +2 -0
  41. 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
+ }
@@ -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,2 @@
1
+ export * from './format';
2
+ export * from './search';
@@ -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
+ }