solanapolis 1.0.0

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 (202) hide show
  1. package/README.md +518 -0
  2. package/bin/solanapolis.js +197 -0
  3. package/convex/_generated/api.d.ts +175 -0
  4. package/convex/_generated/api.js +23 -0
  5. package/convex/_generated/dataModel.d.ts +60 -0
  6. package/convex/_generated/server.d.ts +143 -0
  7. package/convex/_generated/server.js +93 -0
  8. package/convex/agent/conversation.ts +352 -0
  9. package/convex/agent/embeddingsCache.ts +110 -0
  10. package/convex/agent/memory.ts +450 -0
  11. package/convex/agent/schema.ts +53 -0
  12. package/convex/aiChat.ts +54 -0
  13. package/convex/aiTown/agent.ts +382 -0
  14. package/convex/aiTown/agentDescription.ts +27 -0
  15. package/convex/aiTown/agentInputs.ts +155 -0
  16. package/convex/aiTown/agentOperations.ts +178 -0
  17. package/convex/aiTown/conversation.ts +395 -0
  18. package/convex/aiTown/conversationMembership.ts +38 -0
  19. package/convex/aiTown/game.ts +371 -0
  20. package/convex/aiTown/ids.ts +32 -0
  21. package/convex/aiTown/inputHandler.ts +9 -0
  22. package/convex/aiTown/inputs.ts +25 -0
  23. package/convex/aiTown/insertInput.ts +20 -0
  24. package/convex/aiTown/location.ts +32 -0
  25. package/convex/aiTown/main.ts +154 -0
  26. package/convex/aiTown/movement.ts +189 -0
  27. package/convex/aiTown/player.ts +310 -0
  28. package/convex/aiTown/playerDescription.ts +35 -0
  29. package/convex/aiTown/schema.ts +79 -0
  30. package/convex/aiTown/world.ts +65 -0
  31. package/convex/aiTown/worldMap.ts +74 -0
  32. package/convex/chat.ts +79 -0
  33. package/convex/constants.ts +78 -0
  34. package/convex/convex.config.ts +6 -0
  35. package/convex/crons.ts +89 -0
  36. package/convex/engine/abstractGame.ts +199 -0
  37. package/convex/engine/historicalObject.ts +355 -0
  38. package/convex/engine/schema.ts +56 -0
  39. package/convex/http.ts +36 -0
  40. package/convex/init.ts +110 -0
  41. package/convex/messages.ts +53 -0
  42. package/convex/npcCarAgents.ts +415 -0
  43. package/convex/schema.ts +61 -0
  44. package/convex/streaming.ts +23 -0
  45. package/convex/testing.ts +202 -0
  46. package/convex/tsconfig.json +18 -0
  47. package/convex/util/FastIntegerCompression.ts +221 -0
  48. package/convex/util/assertNever.ts +4 -0
  49. package/convex/util/asyncMap.ts +20 -0
  50. package/convex/util/compression.ts +71 -0
  51. package/convex/util/geometry.ts +132 -0
  52. package/convex/util/isSimpleObject.ts +11 -0
  53. package/convex/util/llm.ts +724 -0
  54. package/convex/util/minheap.ts +38 -0
  55. package/convex/util/object.ts +22 -0
  56. package/convex/util/sleep.ts +3 -0
  57. package/convex/util/types.ts +33 -0
  58. package/convex/util/xxhash.ts +228 -0
  59. package/convex/world.ts +257 -0
  60. package/data/animations/campfire.json +45 -0
  61. package/data/animations/gentlesparkle.json +37 -0
  62. package/data/animations/gentlesplash.json +61 -0
  63. package/data/animations/gentlewaterfall.json +61 -0
  64. package/data/animations/windmill.json +78 -0
  65. package/data/characters.ts +121 -0
  66. package/data/convertMap.js +74 -0
  67. package/data/gentle.js +330 -0
  68. package/data/spritesheets/f1.ts +75 -0
  69. package/data/spritesheets/f2.ts +75 -0
  70. package/data/spritesheets/f3.ts +75 -0
  71. package/data/spritesheets/f4.ts +75 -0
  72. package/data/spritesheets/f5.ts +75 -0
  73. package/data/spritesheets/f6.ts +75 -0
  74. package/data/spritesheets/f7.ts +75 -0
  75. package/data/spritesheets/f8.ts +75 -0
  76. package/data/spritesheets/p1.ts +59 -0
  77. package/data/spritesheets/p2.ts +59 -0
  78. package/data/spritesheets/p3.ts +59 -0
  79. package/data/spritesheets/player.ts +59 -0
  80. package/data/spritesheets/types.ts +26 -0
  81. package/eslint.config.mjs +37 -0
  82. package/next.config.ts +7 -0
  83. package/package.json +85 -0
  84. package/postcss.config.mjs +7 -0
  85. package/public/file.svg +1 -0
  86. package/public/globe.svg +1 -0
  87. package/public/helius-icon.svg +84 -0
  88. package/public/helius-logo.svg +85 -0
  89. package/public/next.svg +1 -0
  90. package/public/plane.glb +0 -0
  91. package/public/vercel.svg +1 -0
  92. package/public/window.svg +1 -0
  93. package/scripts/clear-city.ts +74 -0
  94. package/scripts/seed-wallets.ts +185 -0
  95. package/scripts/setup-webhook.ts +73 -0
  96. package/src/app/api/auth/callback/route.ts +6 -0
  97. package/src/app/api/auth/link-wallet/route.ts +6 -0
  98. package/src/app/api/auth/phantom/route.ts +6 -0
  99. package/src/app/api/broadcast-position/route.ts +59 -0
  100. package/src/app/api/leaderboard/route.ts +85 -0
  101. package/src/app/api/network-stats/route.ts +86 -0
  102. package/src/app/api/parcel-reward/route.ts +181 -0
  103. package/src/app/api/queue-status/route.ts +30 -0
  104. package/src/app/api/snapshots/route.ts +37 -0
  105. package/src/app/api/transactions/enhanced/route.ts +57 -0
  106. package/src/app/api/treasury/route.ts +83 -0
  107. package/src/app/api/wallet/[address]/balances/route.ts +124 -0
  108. package/src/app/api/wallet/[address]/identity/route.ts +32 -0
  109. package/src/app/api/wallet/[address]/route.ts +216 -0
  110. package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
  111. package/src/app/api/wallets/route.ts +68 -0
  112. package/src/app/api/webhooks/helius/route.ts +76 -0
  113. package/src/app/auth/callback/page.tsx +29 -0
  114. package/src/app/favicon.ico +0 -0
  115. package/src/app/globals.css +39 -0
  116. package/src/app/layout.tsx +43 -0
  117. package/src/app/page.tsx +16 -0
  118. package/src/components/AITownNPCs.tsx +206 -0
  119. package/src/components/ActivityFeed.tsx +189 -0
  120. package/src/components/AuthPanel.tsx +163 -0
  121. package/src/components/BeachScene.tsx +280 -0
  122. package/src/components/Building.tsx +138 -0
  123. package/src/components/CesiumFlight.tsx +1768 -0
  124. package/src/components/CesiumGlobe.tsx +616 -0
  125. package/src/components/CitizenCard.tsx +442 -0
  126. package/src/components/CitizenCardModal.tsx +153 -0
  127. package/src/components/CityGrid.tsx +313 -0
  128. package/src/components/CityLandmarks.tsx +427 -0
  129. package/src/components/CityScene.tsx +1289 -0
  130. package/src/components/CitySlotsBadge.tsx +68 -0
  131. package/src/components/CockpitHUD.tsx +460 -0
  132. package/src/components/ConvexWrapper.tsx +19 -0
  133. package/src/components/DubaiDistrict.tsx +630 -0
  134. package/src/components/FlightMiniMap.tsx +133 -0
  135. package/src/components/GameChat.tsx +383 -0
  136. package/src/components/GameHUD.tsx +393 -0
  137. package/src/components/Ground.tsx +14 -0
  138. package/src/components/HowItWorksModal.tsx +251 -0
  139. package/src/components/IngestionBanner.tsx +123 -0
  140. package/src/components/InstancedBuildings.tsx +316 -0
  141. package/src/components/InstancedCars.tsx +504 -0
  142. package/src/components/InstancedCityPlanes.tsx +259 -0
  143. package/src/components/InstancedHouses.tsx +246 -0
  144. package/src/components/InstancedLampPosts.tsx +201 -0
  145. package/src/components/InstancedResidentCars.tsx +357 -0
  146. package/src/components/InstancedRoadDashes.tsx +42 -0
  147. package/src/components/InstancedSkyscrapers.tsx +434 -0
  148. package/src/components/InstancedTrees.tsx +67 -0
  149. package/src/components/LeaderboardPanel.tsx +136 -0
  150. package/src/components/MultiplayerPlanes.tsx +128 -0
  151. package/src/components/NetworkStats.tsx +83 -0
  152. package/src/components/NewBuildingSpotlight.tsx +93 -0
  153. package/src/components/ParcelChallengeBanner.tsx +242 -0
  154. package/src/components/ParcelReward.tsx +191 -0
  155. package/src/components/Park.tsx +42 -0
  156. package/src/components/PhantomWrapper.tsx +22 -0
  157. package/src/components/PixelStreamViewer.tsx +335 -0
  158. package/src/components/PlaneMode.tsx +190 -0
  159. package/src/components/PlayerCar.tsx +211 -0
  160. package/src/components/PlayerPlane.tsx +255 -0
  161. package/src/components/ProjectileRenderer.tsx +249 -0
  162. package/src/components/QueueStatusBanner.tsx +86 -0
  163. package/src/components/RealPlayerTags.tsx +82 -0
  164. package/src/components/SceneLighting.tsx +382 -0
  165. package/src/components/SelectionBeam.tsx +59 -0
  166. package/src/components/SwapPanel.tsx +104 -0
  167. package/src/components/SwapParticles.tsx +237 -0
  168. package/src/components/TreasureGate.tsx +505 -0
  169. package/src/components/WalletPanel.tsx +421 -0
  170. package/src/components/WalletSearch.tsx +244 -0
  171. package/src/components/WelcomeOverlay.tsx +135 -0
  172. package/src/components/WindowTooltip.tsx +498 -0
  173. package/src/context/AuthContext.tsx +230 -0
  174. package/src/lib/bot-detection.ts +125 -0
  175. package/src/lib/building-math.ts +136 -0
  176. package/src/lib/building-shader.ts +253 -0
  177. package/src/lib/car-paths.ts +244 -0
  178. package/src/lib/car-system.ts +182 -0
  179. package/src/lib/city-constants.ts +29 -0
  180. package/src/lib/city-slots.ts +35 -0
  181. package/src/lib/city-zoning.ts +64 -0
  182. package/src/lib/collision-map.ts +147 -0
  183. package/src/lib/day-night.ts +252 -0
  184. package/src/lib/export-card.ts +28 -0
  185. package/src/lib/helius-webhook.ts +90 -0
  186. package/src/lib/helius.ts +74 -0
  187. package/src/lib/house-shader.ts +119 -0
  188. package/src/lib/mock-data.ts +56 -0
  189. package/src/lib/multiplayer-manager.ts +329 -0
  190. package/src/lib/plane-physics.ts +66 -0
  191. package/src/lib/player-car.ts +147 -0
  192. package/src/lib/player-plane.ts +200 -0
  193. package/src/lib/projectile-system.ts +272 -0
  194. package/src/lib/skyscraper-types.ts +52 -0
  195. package/src/lib/sound-engine.ts +464 -0
  196. package/src/lib/supabase-admin.ts +9 -0
  197. package/src/lib/supabase.ts +8 -0
  198. package/src/lib/swap-events.ts +70 -0
  199. package/src/middleware.ts +37 -0
  200. package/src/types/phantom.d.ts +16 -0
  201. package/src/types/wallet.ts +20 -0
  202. package/tsconfig.json +34 -0
@@ -0,0 +1,421 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, lazy, Suspense } from "react";
4
+ import { WalletBuilding } from "@/types/wallet";
5
+ import { getBuildingDimensions, floors } from "@/lib/building-math";
6
+ import { useAuth } from "@/context/AuthContext";
7
+ import { createClient } from "@/lib/supabase";
8
+
9
+ const CitizenCardModal = lazy(() => import("./CitizenCardModal"));
10
+
11
+ interface WalletPanelProps {
12
+ wallet: WalletBuilding | null;
13
+ onClose: () => void;
14
+ }
15
+
16
+ interface BuildingProfile {
17
+ x_username: string | null;
18
+ x_avatar_url: string | null;
19
+ }
20
+
21
+ interface WalletIdentity {
22
+ name: string;
23
+ type: string;
24
+ category: string;
25
+ }
26
+
27
+ interface TokenInfo {
28
+ mint: string;
29
+ symbol?: string;
30
+ name?: string;
31
+ image?: string;
32
+ uiAmount: number;
33
+ price?: number;
34
+ value?: number;
35
+ }
36
+
37
+ interface BalanceData {
38
+ solBalance: number;
39
+ tokenCount: number;
40
+ tokens: TokenInfo[];
41
+ }
42
+
43
+ export default function WalletPanel({ wallet, onClose }: WalletPanelProps) {
44
+ const { profile, connectX } = useAuth();
45
+ const [buildingProfile, setBuildingProfile] =
46
+ useState<BuildingProfile | null>(null);
47
+ const [balances, setBalances] = useState<BalanceData | null>(null);
48
+ const [balancesLoading, setBalancesLoading] = useState(false);
49
+ const [showCard, setShowCard] = useState(false);
50
+ const [nowSeconds, setNowSeconds] = useState(() => Math.floor(Date.now() / 1000));
51
+
52
+ useEffect(() => {
53
+ const interval = window.setInterval(() => {
54
+ setNowSeconds(Math.floor(Date.now() / 1000));
55
+ }, 60_000);
56
+ return () => window.clearInterval(interval);
57
+ }, []);
58
+
59
+ // Identity from pre-loaded wallet data
60
+ const identity: WalletIdentity | null = wallet?.identityName
61
+ ? { name: wallet.identityName, type: wallet.identityType ?? "unknown", category: wallet.identityCategory ?? "" }
62
+ : null;
63
+
64
+ const identityAtMatch = identity?.name?.match(/@(\w+)/);
65
+ const identityXHandle = identityAtMatch ? identityAtMatch[1] : null;
66
+ const identityDisplayName = identity?.name ? identity.name.replace(/@\w+/, "").trim() || null : null;
67
+
68
+ useEffect(() => {
69
+ if (!wallet) {
70
+ setBuildingProfile(null);
71
+ setBalances(null);
72
+ setShowCard(false);
73
+ return;
74
+ }
75
+
76
+ const supabase = createClient();
77
+ supabase
78
+ .from("profiles")
79
+ .select("x_username, x_avatar_url")
80
+ .eq("wallet_address", wallet.address)
81
+ .single()
82
+ .then(({ data }) => setBuildingProfile(data));
83
+
84
+ // Fetch live balances via DAS (getAssetsByOwner)
85
+ setBalancesLoading(true);
86
+ fetch(`/api/wallet/${wallet.address}/balances`)
87
+ .then((res) => res.json())
88
+ .then((data) => {
89
+ if (!data.error) setBalances(data);
90
+ })
91
+ .catch(() => { })
92
+ .finally(() => setBalancesLoading(false));
93
+ }, [wallet]);
94
+
95
+ if (!wallet) return null;
96
+
97
+ const dims = getBuildingDimensions(wallet);
98
+ const isOwner = profile?.wallet_address === wallet.address;
99
+ const floorCount = floors(wallet.txnCount);
100
+
101
+ function formatLastActive(blocktime: number | undefined): string {
102
+ if (!blocktime) return "Unknown";
103
+ const days = Math.floor((nowSeconds - blocktime) / 86400);
104
+ if (days === 0) return "Today";
105
+ if (days === 1) return "Yesterday";
106
+ if (days < 30) return `${days} days ago`;
107
+ if (days < 365) return `${Math.floor(days / 30)} months ago`;
108
+ return `${(days / 365).toFixed(1)} years ago`;
109
+ }
110
+
111
+ function formatTokenAmount(amount: number): string {
112
+ if (amount >= 1_000_000) return `${(amount / 1_000_000).toFixed(2)}M`;
113
+ if (amount >= 1_000) return `${(amount / 1_000).toFixed(2)}K`;
114
+ if (amount >= 1) return amount.toFixed(2);
115
+ if (amount >= 0.001) return amount.toFixed(4);
116
+ return amount.toExponential(2);
117
+ }
118
+
119
+ function formatUsd(value: number): string {
120
+ if (value >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`;
121
+ if (value >= 1_000) return `$${(value / 1_000).toFixed(1)}K`;
122
+ if (value >= 1) return `$${value.toFixed(2)}`;
123
+ return `$${value.toFixed(4)}`;
124
+ }
125
+
126
+ // Calculate total portfolio USD value
127
+ const solPrice = balances?.tokens?.length
128
+ ? undefined // we don't have SOL price from DAS, will be shown separately
129
+ : undefined;
130
+ const tokenUsdTotal = balances?.tokens?.reduce((sum, t) => sum + (t.value ?? 0), 0) ?? 0;
131
+
132
+ return (
133
+ <div className="w-full sm:w-80 bg-black/50 backdrop-blur-xl border border-white/[0.08] rounded-t-2xl sm:rounded-2xl p-5 text-white max-h-[60vh] sm:max-h-[calc(100vh-8rem)] overflow-y-auto">
134
+ <div className="flex justify-between items-center mb-4">
135
+ <div className="flex items-center gap-2">
136
+ <h3 className="text-xs font-semibold text-white/40 uppercase tracking-wider">
137
+ Wallet
138
+ </h3>
139
+ {(!wallet.ingestionStatus || wallet.ingestionStatus === "complete") && (
140
+ <button
141
+ onClick={() => setShowCard(true)}
142
+ className="px-2 py-0.5 bg-[#E35930]/15 hover:bg-[#E35930]/25 border border-[#E35930]/20 rounded-lg text-[10px] font-medium text-[#E35930] transition-colors cursor-pointer"
143
+ >
144
+ ID Card
145
+ </button>
146
+ )}
147
+ </div>
148
+ <button
149
+ onClick={onClose}
150
+ className="w-6 h-6 flex items-center justify-center rounded-lg text-white/30 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer"
151
+ >
152
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5">
153
+ <path d="M1 1l8 8M9 1l-8 8" />
154
+ </svg>
155
+ </button>
156
+ </div>
157
+
158
+ {/* Address with Solscan link */}
159
+ <a
160
+ href={`https://solscan.io/account/${wallet.address}`}
161
+ target="_blank"
162
+ rel="noopener noreferrer"
163
+ className="group flex items-center gap-1.5 font-mono text-sm text-purple-300/80 hover:text-purple-300 break-all mb-5 transition-colors"
164
+ >
165
+ {wallet.address}
166
+ <svg className="w-3 h-3 shrink-0 text-white/20 group-hover:text-purple-300/60 transition-colors" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
167
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
168
+ </svg>
169
+ </a>
170
+
171
+ {/* Unified identity + profile section */}
172
+ {(() => {
173
+ const xAvatar = buildingProfile?.x_avatar_url ?? null;
174
+ const xHandle = buildingProfile?.x_username ?? identityXHandle;
175
+ const displayName = identityDisplayName || (identity && !identityXHandle ? identity.name : null);
176
+ const categoryBadge = identity && !identityXHandle ? (identity.category || identity.type) : null;
177
+ const isVerified = !!buildingProfile || !!identityXHandle;
178
+ const showSection = displayName || xHandle || isVerified || categoryBadge;
179
+
180
+ return showSection ? (
181
+ <div className="mb-5 pb-4 border-b border-white/[0.06] space-y-2.5">
182
+ <div className="flex items-center gap-2.5">
183
+ {xAvatar && (
184
+ <img src={xAvatar} alt="" className="w-7 h-7 rounded-full shrink-0" />
185
+ )}
186
+ <div className="flex flex-wrap items-center gap-2 min-w-0">
187
+ {displayName && (
188
+ <span className="text-sm font-medium text-white/80 truncate">{displayName}</span>
189
+ )}
190
+ {xHandle && (
191
+ <a href={`https://x.com/${xHandle}`} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-300 hover:underline truncate">@{xHandle}</a>
192
+ )}
193
+ {isVerified && (
194
+ <span className="flex items-center gap-1 px-2 py-0.5 bg-green-500/10 border border-green-500/15 rounded-lg text-xs text-green-300 shrink-0">
195
+ <svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
196
+ <path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
197
+ </svg>
198
+ Verified
199
+ </span>
200
+ )}
201
+ {categoryBadge && (
202
+ <span className="px-2 py-0.5 bg-white/[0.06] border border-white/[0.08] rounded-lg text-xs text-white/35 shrink-0">
203
+ {categoryBadge}
204
+ </span>
205
+ )}
206
+ </div>
207
+ </div>
208
+ {isOwner && !buildingProfile?.x_username && (
209
+ <button
210
+ onClick={connectX}
211
+ className="flex items-center gap-2 px-3 py-1.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] rounded-xl text-sm text-white/60 transition-colors cursor-pointer"
212
+ >
213
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
214
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
215
+ </svg>
216
+ Connect X to your building
217
+ </button>
218
+ )}
219
+ </div>
220
+ ) : null;
221
+ })()}
222
+
223
+ {wallet.ingestionStatus && wallet.ingestionStatus !== "complete" && (
224
+ <div
225
+ className={`mb-4 px-3 py-2 rounded-xl text-sm ${wallet.ingestionStatus === "failed"
226
+ ? "bg-red-500/10 border border-red-500/15 text-red-300"
227
+ : "bg-yellow-500/10 border border-yellow-500/15 text-yellow-200"
228
+ }`}
229
+ >
230
+ {wallet.ingestionStatus === "failed"
231
+ ? "Ingestion failed"
232
+ : wallet.ingestionStatus === "processing"
233
+ ? "Processing transactions..."
234
+ : "Queued for processing"}
235
+ </div>
236
+ )}
237
+
238
+ {isOwner && (
239
+ <div className="mb-4 px-3 py-2 bg-purple-500/10 border border-purple-500/15 rounded-xl text-sm text-purple-200">
240
+ This is your building
241
+ </div>
242
+ )}
243
+
244
+ {/* Live Balances — SOL + USD */}
245
+ <div className="mb-5 pb-4 border-b border-white/[0.06] space-y-2.5">
246
+ <h4 className="text-xs font-semibold text-white/40 uppercase tracking-wider mb-2">
247
+ Live Balances
248
+ </h4>
249
+ {balancesLoading ? (
250
+ <div className="flex items-center gap-2">
251
+ <div className="w-3 h-3 border-2 border-white/10 border-t-purple-400/60 rounded-full animate-spin" />
252
+ <p className="text-sm text-white/30">Fetching via Helius DAS...</p>
253
+ </div>
254
+ ) : balances ? (
255
+ <>
256
+ <div className="flex justify-between items-center text-sm">
257
+ <span className="text-white/45">SOL Balance</span>
258
+ <span className="font-mono text-white/80 tabular-nums">
259
+ {balances.solBalance.toLocaleString(undefined, {
260
+ maximumFractionDigits: 4,
261
+ })}{" "}
262
+ SOL
263
+ </span>
264
+ </div>
265
+ {tokenUsdTotal > 0 && (
266
+ <div className="flex justify-between items-center text-sm">
267
+ <span className="text-white/45">Token Value</span>
268
+ <span className="font-mono text-emerald-300/70 tabular-nums">
269
+ {formatUsd(tokenUsdTotal)}
270
+ </span>
271
+ </div>
272
+ )}
273
+ <div className="flex justify-between text-sm">
274
+ <span className="text-white/45">Token Accounts</span>
275
+ <span className="font-mono text-white/80">{balances.tokenCount}</span>
276
+ </div>
277
+ </>
278
+ ) : (
279
+ <p className="text-sm text-white/30">Unable to load balances</p>
280
+ )}
281
+ <div className="flex justify-between text-sm">
282
+ <span className="text-white/45">Unique Tokens Swapped</span>
283
+ <span className="font-mono text-white/80">
284
+ {wallet.uniqueTokensSwapped ?? 0}
285
+ </span>
286
+ </div>
287
+ <div className="flex justify-between text-sm">
288
+ <span className="text-white/45">Last Active</span>
289
+ <span className="font-mono text-white/80">
290
+ {formatLastActive(wallet.latestBlocktime)}
291
+ </span>
292
+ </div>
293
+ </div>
294
+
295
+ {/* Top tokens with images and USD values */}
296
+ {balances && balances.tokens.length > 0 && (
297
+ <div className="mb-5 pb-4 border-b border-white/[0.06]">
298
+ <h4 className="text-xs font-semibold text-white/40 uppercase tracking-wider mb-2.5">
299
+ Top Holdings
300
+ </h4>
301
+ <div className="space-y-1.5">
302
+ {balances.tokens.slice(0, 10).map((t) => (
303
+ <a
304
+ key={t.mint}
305
+ href={`https://solscan.io/token/${t.mint}`}
306
+ target="_blank"
307
+ rel="noopener noreferrer"
308
+ className="flex items-center gap-2 text-sm hover:bg-white/[0.03] rounded-lg px-1.5 py-1 -mx-1.5 transition-colors group"
309
+ >
310
+ {/* Token image */}
311
+ {t.image ? (
312
+ <img
313
+ src={t.image}
314
+ alt=""
315
+ className="w-5 h-5 rounded-full shrink-0 bg-white/5"
316
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
317
+ />
318
+ ) : (
319
+ <div className="w-5 h-5 rounded-full bg-gradient-to-br from-purple-500/20 to-blue-500/20 shrink-0 flex items-center justify-center text-[8px] text-white/30">
320
+ {(t.symbol || '?')[0]}
321
+ </div>
322
+ )}
323
+ <div className="flex-1 min-w-0">
324
+ <span className="text-white/60 truncate group-hover:text-white/80 transition-colors">
325
+ {t.symbol || t.name || `${t.mint.slice(0, 4)}...${t.mint.slice(-4)}`}
326
+ </span>
327
+ </div>
328
+ <div className="text-right shrink-0">
329
+ <div className="font-mono text-white/45 text-xs tabular-nums">
330
+ {formatTokenAmount(t.uiAmount)}
331
+ </div>
332
+ {t.value != null && t.value > 0 && (
333
+ <div className="font-mono text-emerald-400/50 text-[10px] tabular-nums">
334
+ {formatUsd(t.value)}
335
+ </div>
336
+ )}
337
+ </div>
338
+ </a>
339
+ ))}
340
+ </div>
341
+ </div>
342
+ )}
343
+
344
+ {/* On-chain stats → Building dimensions */}
345
+ <div className="space-y-2.5 text-sm">
346
+ <h4 className="text-xs font-semibold text-white/40 uppercase tracking-wider mb-1">
347
+ On-Chain Stats → Building
348
+ </h4>
349
+ <div className="flex justify-between">
350
+ <span className="text-white/45">Transactions</span>
351
+ <span className="font-mono text-white/80">
352
+ {wallet.txnCount.toLocaleString()}
353
+ <span className="text-white/25 text-xs ml-1">→ {floorCount}F</span>
354
+ </span>
355
+ </div>
356
+ <div className="flex justify-between">
357
+ <span className="text-white/45">Age</span>
358
+ <span className="font-mono text-white/80">{wallet.walletAgeDays} days</span>
359
+ </div>
360
+ <div className="flex justify-between">
361
+ <span className="text-white/45">Volume</span>
362
+ <span className="font-mono text-white/80">
363
+ {wallet.volumeTraded.toLocaleString()} SOL
364
+ <span className="text-white/25 text-xs ml-1">→ w{dims.width.toFixed(1)}</span>
365
+ </span>
366
+ </div>
367
+ <div className="flex justify-between">
368
+ <span className="text-white/45">Fees Paid</span>
369
+ <span className="font-mono text-white/80">
370
+ {wallet.feesPaid.toLocaleString()} SOL
371
+ </span>
372
+ </div>
373
+ </div>
374
+
375
+ {/* Building dimensions visual */}
376
+ <div className="mt-5 pt-4 border-t border-white/[0.06]">
377
+ <div className="flex items-end gap-3">
378
+ {/* Mini building preview */}
379
+ <div
380
+ className="bg-gradient-to-t from-slate-600/40 to-slate-400/20 border border-white/10 rounded-sm"
381
+ style={{
382
+ width: Math.max(16, dims.width * 12),
383
+ height: Math.max(12, Math.min(60, dims.height * 1.2)),
384
+ }}
385
+ />
386
+ <div className="space-y-1 text-xs text-white/30 flex-1">
387
+ <div className="flex justify-between">
388
+ <span>Height</span>
389
+ <span className="font-mono">{floorCount} floors ({dims.height.toFixed(1)}u)</span>
390
+ </div>
391
+ <div className="flex justify-between">
392
+ <span>Width</span>
393
+ <span className="font-mono">{dims.width.toFixed(2)}u</span>
394
+ </div>
395
+ <div className="flex justify-between">
396
+ <span>Windows</span>
397
+ <span className="font-mono">{wallet.uniqueTokensSwapped ?? 0} token diversity</span>
398
+ </div>
399
+ </div>
400
+ </div>
401
+ <p className="text-[8px] text-white/15 mt-2">
402
+ Building stats powered by Helius • getSignaturesForAddress • getAssetsByOwner • getWalletIdentity
403
+ </p>
404
+ </div>
405
+
406
+ {showCard && (
407
+ <Suspense fallback={null}>
408
+ <CitizenCardModal
409
+ wallet={wallet}
410
+ identityName={
411
+ buildingProfile?.x_username
412
+ ? `@${buildingProfile.x_username}`
413
+ : identityDisplayName || identity?.name || null
414
+ }
415
+ onClose={() => setShowCard(false)}
416
+ />
417
+ </Suspense>
418
+ )}
419
+ </div>
420
+ );
421
+ }
@@ -0,0 +1,244 @@
1
+ "use client";
2
+
3
+ import { useState, useMemo, useRef } from "react";
4
+ import { PlacedWallet } from "@/types/wallet";
5
+ import { getBuildingDimensions, getWalletWorldPosition } from "@/lib/building-math";
6
+
7
+ type Status = "idle" | "searching" | "found" | "not-found" | "error";
8
+
9
+ interface WalletSearchProps {
10
+ wallets: PlacedWallet[];
11
+ onSelect: (wallet: PlacedWallet, position: [number, number, number]) => void;
12
+ onRefetch: () => Promise<PlacedWallet[]>;
13
+ }
14
+
15
+ export default function WalletSearch({ wallets, onSelect, onRefetch }: WalletSearchProps) {
16
+ const [query, setQuery] = useState("");
17
+ const [status, setStatus] = useState<Status>("idle");
18
+ const [message, setMessage] = useState("");
19
+ const [showSuggestions, setShowSuggestions] = useState(false);
20
+ const blurTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
21
+
22
+ const suggestions = useMemo(() => {
23
+ const q = query.trim().toLowerCase();
24
+ if (q.length < 2) return [];
25
+
26
+ const scored: { wallet: PlacedWallet; score: number; matchField: string }[] = [];
27
+
28
+ for (const w of wallets) {
29
+ const name = w.identityName?.toLowerCase() ?? "";
30
+ const xUser = w.xUsername?.toLowerCase() ?? "";
31
+ const addr = w.address.toLowerCase();
32
+
33
+ let best = -1;
34
+ let field = "";
35
+
36
+ // Name matches (highest priority)
37
+ if (name && name.startsWith(q)) { best = 5; field = "name"; }
38
+ else if (name && name.includes(q)) { best = 4; field = "name"; }
39
+
40
+ // X handle matches
41
+ if (xUser && xUser.startsWith(q.replace(/^@/, ""))) {
42
+ if (best < 5) { best = 5; field = "x"; }
43
+ } else if (xUser && xUser.includes(q.replace(/^@/, ""))) {
44
+ if (best < 4) { best = 4; field = "x"; }
45
+ }
46
+
47
+ // Address matches
48
+ if (best < 0) {
49
+ if (addr.startsWith(q)) { best = 1; field = "address"; }
50
+ else if (addr.includes(q)) { best = 0; field = "address"; }
51
+ }
52
+
53
+ if (best >= 0) scored.push({ wallet: w, score: best, matchField: field });
54
+ }
55
+
56
+ scored.sort((a, b) => b.score - a.score);
57
+ return scored.slice(0, 3);
58
+ }, [query, wallets]);
59
+
60
+ function selectWallet(wallet: PlacedWallet) {
61
+ const dims = getBuildingDimensions(wallet);
62
+ const pos = getWalletWorldPosition(wallet, dims);
63
+ onSelect(wallet, pos);
64
+ setQuery("");
65
+ setShowSuggestions(false);
66
+ setStatus("found");
67
+ setMessage("Flying to building...");
68
+ setTimeout(() => setStatus("idle"), 2000);
69
+ }
70
+
71
+ function selectFromArray(address: string, arr: PlacedWallet[]): boolean {
72
+ const wallet = arr.find((w) => w.address === address);
73
+ if (!wallet) return false;
74
+ selectWallet(wallet);
75
+ return true;
76
+ }
77
+
78
+ async function handleSearch(e: React.FormEvent) {
79
+ e.preventDefault();
80
+ setShowSuggestions(false);
81
+ const address = query.trim();
82
+ if (!address) return;
83
+
84
+ // If there's a top suggestion that matches, use it directly
85
+ if (suggestions.length > 0) {
86
+ const q = address.toLowerCase().replace(/^@/, "");
87
+ const exact = suggestions.find(
88
+ (s) =>
89
+ s.wallet.identityName?.toLowerCase() === address.toLowerCase() ||
90
+ s.wallet.xUsername?.toLowerCase() === q
91
+ );
92
+ if (exact) {
93
+ selectWallet(exact.wallet);
94
+ return;
95
+ }
96
+ // If there are suggestions but no exact match, use the top one
97
+ selectWallet(suggestions[0].wallet);
98
+ return;
99
+ }
100
+
101
+ // Check local wallets first
102
+ if (selectFromArray(address, wallets)) return;
103
+
104
+ // Not in local array — try to look up or auto-add via API
105
+ setStatus("searching");
106
+ setMessage("Adding wallet to city...");
107
+
108
+ try {
109
+ const res = await fetch(`/api/wallet/${address}`);
110
+
111
+ if (res.status === 400) {
112
+ setStatus("error");
113
+ setMessage("Invalid Solana address");
114
+ return;
115
+ }
116
+
117
+ if (res.status === 404) {
118
+ setStatus("not-found");
119
+ setMessage("No transactions found for this wallet");
120
+ return;
121
+ }
122
+
123
+ if (!res.ok) {
124
+ const data = await res.json().catch(() => ({}));
125
+ setStatus("error");
126
+ setMessage(data.error || "Something went wrong");
127
+ return;
128
+ }
129
+
130
+ const data = await res.json();
131
+
132
+ if (data.isNew) {
133
+ setMessage("Building placed! Loading...");
134
+ } else {
135
+ setMessage("Found! Loading building...");
136
+ }
137
+
138
+ // Refetch wallets to include the new/existing one
139
+ const fresh = await onRefetch();
140
+ if (selectFromArray(address, fresh)) {
141
+ setStatus("found");
142
+ setMessage("Flying to building...");
143
+ } else {
144
+ setStatus("found");
145
+ setMessage("Wallet added to city!");
146
+ setTimeout(() => {
147
+ onRefetch().then((newest) => selectFromArray(address, newest));
148
+ }, 1000);
149
+ }
150
+ } catch {
151
+ setStatus("error");
152
+ setMessage("Failed to add wallet");
153
+ }
154
+ }
155
+
156
+ const statusColor =
157
+ status === "found"
158
+ ? "text-green-400/80"
159
+ : status === "error" || status === "not-found"
160
+ ? "text-red-400/80"
161
+ : "text-white/50";
162
+
163
+ function shortAddr(addr: string) {
164
+ return addr.slice(0, 4) + "..." + addr.slice(-4);
165
+ }
166
+
167
+ return (
168
+ <form onSubmit={handleSearch} className="relative">
169
+ <div className="flex gap-2">
170
+ <input
171
+ type="text"
172
+ placeholder="Paste wallet address to add..."
173
+ value={query}
174
+ onChange={(e) => {
175
+ setQuery(e.target.value);
176
+ setStatus("idle");
177
+ setShowSuggestions(true);
178
+ }}
179
+ onFocus={() => setShowSuggestions(true)}
180
+ onBlur={() => {
181
+ blurTimeout.current = setTimeout(() => setShowSuggestions(false), 150);
182
+ }}
183
+ className="w-full sm:w-72 px-4 py-2.5 bg-black/50 backdrop-blur-xl border border-white/[0.08] rounded-xl text-white placeholder-white/25 font-mono text-sm focus:outline-none focus:border-[#E35930]/50 focus:ring-1 focus:ring-[#E35930]/30 transition-colors"
184
+ />
185
+ <button
186
+ type="submit"
187
+ disabled={!query.trim() || status === "searching"}
188
+ className="px-4 py-2.5 bg-white/30 hover:bg-white/40 backdrop-blur-xl border border-white/30 rounded-xl text-white text-sm font-medium transition-colors disabled:opacity-40 cursor-pointer"
189
+ >
190
+ {status === "searching" ? "..." : "Add"}
191
+ </button>
192
+ </div>
193
+
194
+ {showSuggestions && suggestions.length > 0 && status === "idle" && (
195
+ <div className="absolute top-full left-0 mt-1.5 w-full sm:w-72 bg-black/80 backdrop-blur-xl border border-white/[0.08] rounded-xl overflow-hidden z-30">
196
+ {suggestions.map(({ wallet: w }) => {
197
+ const displayName = w.identityName?.replace(/@\w+/, "").trim() || null;
198
+ const xHandle = w.xUsername || w.identityName?.match(/@(\w+)/)?.[1] || null;
199
+
200
+ return (
201
+ <button
202
+ key={w.address}
203
+ type="button"
204
+ onMouseDown={() => {
205
+ clearTimeout(blurTimeout.current);
206
+ selectWallet(w);
207
+ }}
208
+ className="w-full px-3.5 py-2.5 flex items-center gap-2.5 hover:bg-white/10 transition-colors text-left cursor-pointer"
209
+ >
210
+ <div className="min-w-0 flex-1">
211
+ {displayName || xHandle ? (
212
+ <>
213
+ <div className="flex items-center gap-1.5">
214
+ {displayName && (
215
+ <span className="text-sm text-white truncate">{displayName}</span>
216
+ )}
217
+ {xHandle && (
218
+ <span className="text-xs text-blue-300 truncate">@{xHandle}</span>
219
+ )}
220
+ </div>
221
+ <p className="text-xs text-white/30 font-mono">{shortAddr(w.address)}</p>
222
+ </>
223
+ ) : (
224
+ <p className="text-sm text-white font-mono">{shortAddr(w.address)}</p>
225
+ )}
226
+ </div>
227
+ <span className="text-xs text-white/20 shrink-0">{w.txnCount.toLocaleString()} txns</span>
228
+ </button>
229
+ );
230
+ })}
231
+ </div>
232
+ )}
233
+
234
+ {status !== "idle" && (
235
+ <p className={`text-xs mt-2 text-center ${statusColor}`}>
236
+ {status === "searching" && (
237
+ <span className="inline-block w-2.5 h-2.5 border-2 border-current border-t-transparent rounded-full animate-spin mr-1.5 align-middle" />
238
+ )}
239
+ {message}
240
+ </p>
241
+ )}
242
+ </form>
243
+ );
244
+ }