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,135 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useRef } from "react";
4
+ import { WalletBuilding } from "@/types/wallet";
5
+ import { useAuth } from "@/context/AuthContext";
6
+
7
+ interface WelcomeOverlayProps {
8
+ onExplore: () => void;
9
+ onWalletSubmit: (wallet: WalletBuilding) => void;
10
+ }
11
+
12
+ export default function WelcomeOverlay({
13
+ onExplore,
14
+ onWalletSubmit,
15
+ }: WelcomeOverlayProps) {
16
+ const { profile } = useAuth();
17
+ const [address, setAddress] = useState("");
18
+ const [loading, setLoading] = useState(false);
19
+ const [error, setError] = useState<string | null>(null);
20
+ const hasAutoSubmitted = useRef(false);
21
+
22
+ // Auto-submit when user has a verified wallet address
23
+ useEffect(() => {
24
+ if (!profile?.wallet_address || hasAutoSubmitted.current || loading) return;
25
+ hasAutoSubmitted.current = true;
26
+ setAddress(profile.wallet_address);
27
+
28
+ (async () => {
29
+ setLoading(true);
30
+ setError(null);
31
+ try {
32
+ const wallet = await fetchWallet(profile.wallet_address!);
33
+ onWalletSubmit(wallet);
34
+ } catch (err) {
35
+ setError(err instanceof Error ? err.message : "Something went wrong");
36
+ } finally {
37
+ setLoading(false);
38
+ }
39
+ })();
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, [profile?.wallet_address]);
42
+
43
+ async function fetchWallet(walletAddress: string): Promise<WalletBuilding> {
44
+ const res = await fetch(`/api/wallet/${walletAddress}`);
45
+ if (!res.ok) {
46
+ const body = await res.json().catch(() => null);
47
+ throw new Error(body?.error ?? `Wallet not found`);
48
+ }
49
+ const stats = await res.json();
50
+ return {
51
+ address: stats.address,
52
+ txnCount: stats.txnCount,
53
+ walletAgeDays: stats.walletAgeDays,
54
+ volumeTraded: stats.volumeTraded,
55
+ feesPaid: stats.feesPaid,
56
+ ingestionStatus: stats.ingestionStatus,
57
+ };
58
+ }
59
+
60
+ async function handleSubmit(e: React.FormEvent) {
61
+ e.preventDefault();
62
+ const trimmed = address.trim();
63
+ if (!trimmed) return;
64
+
65
+ setLoading(true);
66
+ setError(null);
67
+ try {
68
+ const wallet = await fetchWallet(trimmed);
69
+ onWalletSubmit(wallet);
70
+ } catch (err) {
71
+ setError(err instanceof Error ? err.message : "Something went wrong");
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ }
76
+
77
+ return (
78
+ <div className="absolute inset-0 z-20 flex items-center justify-center bg-[#0a0a12]/90 backdrop-blur-sm">
79
+ <div className="max-w-sm w-full px-4 sm:px-6 text-center space-y-6">
80
+ <div className="space-y-2">
81
+ <div className="flex items-center justify-center gap-2.5">
82
+ <img src="/helius-icon.svg" alt="Helius" className="w-7 h-7 sm:w-8 sm:h-8" />
83
+ <h1 className="text-2xl sm:text-3xl font-bold tracking-tight" style={{ color: "#E35930" }}>
84
+ Solanapolis
85
+ </h1>
86
+ </div>
87
+ <p className="text-sm text-white/40">
88
+ The Solana City — powered by Helius
89
+ </p>
90
+ </div>
91
+
92
+ <form onSubmit={handleSubmit} className="space-y-3">
93
+ <input
94
+ type="text"
95
+ placeholder="Wallet address"
96
+ value={address}
97
+ onChange={(e) => setAddress(e.target.value)}
98
+ disabled={loading}
99
+ className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/25 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-[#E35930]/50 focus:border-[#E35930] transition-colors disabled:opacity-50"
100
+ />
101
+ <button
102
+ type="submit"
103
+ disabled={loading || !address.trim()}
104
+ className="w-full px-4 py-2.5 bg-[#E35930]/80 hover:bg-[#E35930] rounded-lg text-white text-sm font-medium transition-colors disabled:opacity-50"
105
+ >
106
+ {loading ? (
107
+ <span className="flex items-center justify-center gap-2">
108
+ <span className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" />
109
+ Looking up wallet...
110
+ </span>
111
+ ) : (
112
+ "Look up wallet"
113
+ )}
114
+ </button>
115
+ </form>
116
+
117
+ {error && <p className="text-red-400/80 text-xs">{error}</p>}
118
+
119
+ <div className="flex items-center gap-3">
120
+ <div className="flex-1 h-px bg-white/10" />
121
+ <span className="text-white/20 text-xs">or</span>
122
+ <div className="flex-1 h-px bg-white/10" />
123
+ </div>
124
+
125
+ <button
126
+ onClick={onExplore}
127
+ disabled={loading}
128
+ className="w-full px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-white/60 text-sm transition-colors disabled:opacity-50 cursor-pointer"
129
+ >
130
+ Explore city
131
+ </button>
132
+ </div>
133
+ </div>
134
+ );
135
+ }
@@ -0,0 +1,498 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+
5
+ interface TokenInfo {
6
+ mint: string;
7
+ symbol?: string;
8
+ name?: string;
9
+ description?: string;
10
+ image?: string;
11
+ uiAmount?: number;
12
+ price?: number;
13
+ value?: number;
14
+ supply?: number;
15
+ marketCap?: number;
16
+ lastTraded?: number;
17
+ }
18
+
19
+ interface TradedTokenApi {
20
+ mint: string;
21
+ symbol?: string;
22
+ name?: string;
23
+ image?: string;
24
+ lastTraded?: number;
25
+ }
26
+
27
+ export interface WindowHoverInfo {
28
+ address: string;
29
+ tokenIndex: number;
30
+ screenX: number;
31
+ screenY: number;
32
+ mode: "building" | "token" | "swap";
33
+ // Identity fields (pre-loaded from wallets bulk response)
34
+ identityName?: string | null;
35
+ identityType?: string | null;
36
+ identityCategory?: string | null;
37
+ // Swap-specific fields (only when mode === "swap")
38
+ swapSignature?: string;
39
+ swapTokenIn?: string;
40
+ swapTokenOut?: string;
41
+ swapAmountSol?: number;
42
+ }
43
+
44
+ const tokenCache = new Map<string, TokenInfo[]>();
45
+ const pendingBalances = new Set<string>();
46
+ const pendingTraded = new Set<string>();
47
+
48
+ function formatNum(n: number): string {
49
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
50
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
51
+ if (n >= 1_000) return `${(n / 1_000).toFixed(2)}K`;
52
+ if (n >= 1) return n.toFixed(2);
53
+ if (n >= 0.001) return n.toFixed(4);
54
+ return n.toExponential(2);
55
+ }
56
+
57
+ function formatUsd(n: number): string {
58
+ if (n >= 1_000_000) return `$${(n / 1_000_000).toFixed(2)}M`;
59
+ if (n >= 1_000) return `$${(n / 1_000).toFixed(2)}K`;
60
+ if (n >= 0.01) return `$${n.toFixed(2)}`;
61
+ if (n > 0) return `$${n.toFixed(6)}`;
62
+ return "$0";
63
+ }
64
+
65
+ function formatTimeAgo(unixTimestamp: number): string {
66
+ const seconds = Math.floor(Date.now() / 1000 - unixTimestamp);
67
+ if (seconds < 60) return "just now";
68
+ const minutes = Math.floor(seconds / 60);
69
+ if (minutes < 60) return `${minutes}m ago`;
70
+ const hours = Math.floor(minutes / 60);
71
+ if (hours < 24) return `${hours}h ago`;
72
+ const days = Math.floor(hours / 24);
73
+ if (days < 30) return `${days}d ago`;
74
+ const months = Math.floor(days / 30);
75
+ if (months < 12) return `${months}mo ago`;
76
+ const years = Math.floor(days / 365);
77
+ return `${years}y ago`;
78
+ }
79
+
80
+ // Intermediate caches for merging
81
+ const balancesCache = new Map<string, TokenInfo[]>();
82
+ const tradedCache = new Map<string, TokenInfo[]>();
83
+
84
+ function mergeTokens(address: string): TokenInfo[] {
85
+ const balances = balancesCache.get(address) ?? [];
86
+ const traded = tradedCache.get(address) ?? [];
87
+
88
+ // Build a lookup of lastTraded timestamps from traded list
89
+ const tradedMap = new Map<string, TokenInfo>();
90
+ for (const t of traded) {
91
+ tradedMap.set(t.mint, t);
92
+ }
93
+
94
+ const seen = new Set<string>();
95
+ const combined: TokenInfo[] = [];
96
+
97
+ // Current holdings first — merge in lastTraded if available
98
+ for (const t of balances) {
99
+ seen.add(t.mint);
100
+ const tradedInfo = tradedMap.get(t.mint);
101
+ combined.push(tradedInfo?.lastTraded != null ? { ...t, lastTraded: tradedInfo.lastTraded } : t);
102
+ }
103
+
104
+ // Then historical traded tokens not currently held
105
+ for (const t of traded) {
106
+ if (!seen.has(t.mint)) {
107
+ seen.add(t.mint);
108
+ combined.push(t);
109
+ }
110
+ }
111
+
112
+ return combined;
113
+ }
114
+
115
+ // --- Building profile cache ---
116
+ interface BuildingProfile {
117
+ x_username: string | null;
118
+ x_avatar_url: string | null;
119
+ }
120
+
121
+ const profileCache = new Map<string, BuildingProfile | null>();
122
+ const pendingProfiles = new Set<string>();
123
+
124
+ function BuildingTooltip({
125
+ address,
126
+ screenX,
127
+ screenY,
128
+ identityName,
129
+ identityType,
130
+ identityCategory,
131
+ }: {
132
+ address: string;
133
+ screenX: number;
134
+ screenY: number;
135
+ identityName?: string | null;
136
+ identityType?: string | null;
137
+ identityCategory?: string | null;
138
+ }) {
139
+ const [profile, setProfile] = useState<BuildingProfile | null | undefined>(
140
+ profileCache.has(address) ? profileCache.get(address)! : undefined,
141
+ );
142
+
143
+ useEffect(() => {
144
+ if (profileCache.has(address)) {
145
+ setProfile(profileCache.get(address)!);
146
+ } else if (!pendingProfiles.has(address)) {
147
+ pendingProfiles.add(address);
148
+ import("@/lib/supabase").then(({ createClient }) => {
149
+ const supabase = createClient();
150
+ Promise.resolve(
151
+ supabase
152
+ .from("profiles")
153
+ .select("x_username, x_avatar_url")
154
+ .eq("wallet_address", address)
155
+ .single()
156
+ )
157
+ .then(({ data }) => {
158
+ profileCache.set(address, data ?? null);
159
+ pendingProfiles.delete(address);
160
+ setProfile(data ?? null);
161
+ })
162
+ .catch(() => {
163
+ profileCache.set(address, null);
164
+ pendingProfiles.delete(address);
165
+ setProfile(null);
166
+ });
167
+ });
168
+ }
169
+ }, [address]);
170
+
171
+ // Identity is pre-loaded from the wallets bulk response — no fetch needed
172
+ const identity = identityName ? { name: identityName, type: identityType ?? "unknown", category: identityCategory ?? "" } : null;
173
+
174
+ // Extract @handle from identity name (formats: "@handle", "Display Name @handle")
175
+ const identityAtMatch = identity?.name?.match(/@(\w+)/);
176
+ const identityXHandle = identityAtMatch ? identityAtMatch[1] : null;
177
+ const identityDisplayName = identity?.name ? identity.name.replace(/@\w+/, "").trim() || null : null;
178
+
179
+ const shortAddress = `${address.slice(0, 4)}...${address.slice(-4)}`;
180
+ const isVerified = profile !== undefined && profile !== null;
181
+ const hasX = (isVerified && !!profile.x_username) || !!identityXHandle;
182
+ const xUsername = profile?.x_username ?? identityXHandle;
183
+ const xAvatar = profile?.x_avatar_url ?? null;
184
+
185
+ const tooltipWidth = 220;
186
+ const tooltipHeight = 80;
187
+ let left = screenX + 16;
188
+ let top = screenY - 12;
189
+ if (typeof window !== "undefined") {
190
+ if (left + tooltipWidth > window.innerWidth - 8) left = screenX - tooltipWidth - 8;
191
+ if (top + tooltipHeight > window.innerHeight - 8) top = screenY - tooltipHeight;
192
+ if (top < 8) top = 8;
193
+ }
194
+
195
+ return (
196
+ <div
197
+ className="fixed z-50 bg-black/60 backdrop-blur-xl border border-white/[0.08] rounded-2xl p-3.5 pointer-events-none"
198
+ style={{ left, top, width: tooltipWidth }}
199
+ >
200
+ {/* Row 1: Display name */}
201
+ {profile === undefined && !identity ? (
202
+ <div className="text-xs text-white/30 mb-1">Loading...</div>
203
+ ) : identityDisplayName ? (
204
+ <div className="flex items-center gap-2 mb-1">
205
+ {xAvatar && (
206
+ <img src={xAvatar} alt="" className="w-5 h-5 rounded-full shrink-0" />
207
+ )}
208
+ <span className="text-sm font-medium text-white/80 truncate">{identityDisplayName}</span>
209
+ </div>
210
+ ) : identity && !identityXHandle ? (
211
+ <div className="flex items-center gap-2 mb-1">
212
+ <span className="text-sm font-medium text-white/80 truncate">{identity.name}</span>
213
+ <span className="px-1.5 py-0.5 bg-white/[0.06] border border-white/[0.08] rounded-lg text-xs text-white/35 shrink-0">
214
+ {identity.category || identity.type}
215
+ </span>
216
+ </div>
217
+ ) : null}
218
+
219
+ {/* Row 2: X handle + verified badge */}
220
+ {hasX ? (
221
+ <div className="flex items-center gap-2 mb-1.5">
222
+ {!identityDisplayName && xAvatar && (
223
+ <img src={xAvatar} alt="" className="w-5 h-5 rounded-full shrink-0" />
224
+ )}
225
+ <a href={`https://x.com/${xUsername}`} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-300 truncate hover:underline pointer-events-auto">@{xUsername}</a>
226
+ <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-500/10 border border-green-500/15 rounded-lg text-xs text-green-300 shrink-0">
227
+ <svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
228
+ <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" />
229
+ </svg>
230
+ Verified
231
+ </span>
232
+ </div>
233
+ ) : isVerified ? (
234
+ <div className="flex items-center gap-1.5 mb-1.5">
235
+ <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-500/10 border border-green-500/15 rounded-lg text-xs text-green-300">
236
+ <svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
237
+ <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" />
238
+ </svg>
239
+ Verified
240
+ </span>
241
+ </div>
242
+ ) : !identity ? (
243
+ <div className="text-xs text-white/30 mb-1.5">Unverified</div>
244
+ ) : null}
245
+
246
+ {/* Row 3: Wallet address */}
247
+ <div className="font-mono text-xs text-purple-300/50">{shortAddress}</div>
248
+ </div>
249
+ );
250
+ }
251
+
252
+ // --- Token tooltip (existing logic) ---
253
+ function TokenTooltip({ address, tokenIndex, screenX, screenY }: { address: string; tokenIndex: number; screenX: number; screenY: number }) {
254
+ const [tokens, setTokens] = useState<TokenInfo[] | null>(() => tokenCache.get(address) ?? null);
255
+
256
+ useEffect(() => {
257
+ if (tokenCache.has(address)) {
258
+ return;
259
+ }
260
+
261
+ let balancesDone = balancesCache.has(address);
262
+ let tradedDone = tradedCache.has(address);
263
+
264
+ const tryMerge = () => {
265
+ if (balancesDone && tradedDone) {
266
+ const merged = mergeTokens(address);
267
+ tokenCache.set(address, merged);
268
+ setTokens(merged);
269
+ }
270
+ };
271
+
272
+ if (!balancesDone && !pendingBalances.has(address)) {
273
+ pendingBalances.add(address);
274
+ fetch(`/api/wallet/${address}/balances`)
275
+ .then((r) => r.json())
276
+ .then((data) => {
277
+ balancesCache.set(address, data.tokens ?? []);
278
+ pendingBalances.delete(address);
279
+ balancesDone = true;
280
+ tryMerge();
281
+ })
282
+ .catch(() => {
283
+ pendingBalances.delete(address);
284
+ balancesCache.set(address, []);
285
+ balancesDone = true;
286
+ tryMerge();
287
+ });
288
+ }
289
+
290
+ if (!tradedDone && !pendingTraded.has(address)) {
291
+ pendingTraded.add(address);
292
+ fetch(`/api/wallet/${address}/traded-tokens`)
293
+ .then((r) => r.json())
294
+ .then((data) => {
295
+ const traded: TokenInfo[] = ((data.mints ?? []) as TradedTokenApi[]).map((m) => ({
296
+ mint: m.mint,
297
+ symbol: m.symbol,
298
+ name: m.name,
299
+ image: m.image,
300
+ lastTraded: m.lastTraded,
301
+ }));
302
+ tradedCache.set(address, traded);
303
+ pendingTraded.delete(address);
304
+ tradedDone = true;
305
+ tryMerge();
306
+ })
307
+ .catch(() => {
308
+ pendingTraded.delete(address);
309
+ tradedCache.set(address, []);
310
+ tradedDone = true;
311
+ tryMerge();
312
+ });
313
+ }
314
+
315
+ if (balancesDone && tradedDone) {
316
+ tryMerge();
317
+ }
318
+ }, [address]);
319
+
320
+ if (!tokens || tokens.length === 0) return null;
321
+
322
+ const token = tokens[tokenIndex % tokens.length];
323
+ const label =
324
+ token.symbol ||
325
+ token.name ||
326
+ `${token.mint.slice(0, 4)}...${token.mint.slice(-4)}`;
327
+
328
+ const tooltipWidth = 240;
329
+ const tooltipHeight = 180;
330
+ let left = screenX + 16;
331
+ let top = screenY - 12;
332
+ if (typeof window !== "undefined") {
333
+ if (left + tooltipWidth > window.innerWidth - 8) left = screenX - tooltipWidth - 8;
334
+ if (top + tooltipHeight > window.innerHeight - 8) top = screenY - tooltipHeight;
335
+ if (top < 8) top = 8;
336
+ }
337
+
338
+ return (
339
+ <div
340
+ className="fixed z-50 bg-black/60 backdrop-blur-xl border border-white/[0.08] rounded-2xl p-3.5 pointer-events-none"
341
+ style={{ left, top, width: tooltipWidth }}
342
+ >
343
+ <div className="flex items-center gap-2.5 mb-2.5">
344
+ {token.image && (
345
+ <img src={token.image} alt="" className="w-6 h-6 rounded-full shrink-0" />
346
+ )}
347
+ <div className="min-w-0">
348
+ <div className="text-sm font-medium text-white truncate">{label}</div>
349
+ {token.name && token.symbol && token.name !== token.symbol && (
350
+ <div className="text-xs text-white/35 truncate">{token.name}</div>
351
+ )}
352
+ </div>
353
+ </div>
354
+
355
+ <div className="space-y-1.5 text-xs">
356
+ {token.uiAmount != null && (
357
+ <div className="flex justify-between">
358
+ <span className="text-white/35">Balance</span>
359
+ <span className="text-white/70 font-mono">{formatNum(token.uiAmount)}</span>
360
+ </div>
361
+ )}
362
+ {token.price != null && (
363
+ <div className="flex justify-between">
364
+ <span className="text-white/35">Price</span>
365
+ <span className="text-white/70 font-mono">{formatUsd(token.price)}</span>
366
+ </div>
367
+ )}
368
+ {token.value != null && (
369
+ <div className="flex justify-between">
370
+ <span className="text-white/35">Value</span>
371
+ <span className="text-white/70 font-mono">{formatUsd(token.value)}</span>
372
+ </div>
373
+ )}
374
+ {token.supply != null && (
375
+ <div className="flex justify-between">
376
+ <span className="text-white/35">Supply</span>
377
+ <span className="text-white/70 font-mono">{formatNum(token.supply)}</span>
378
+ </div>
379
+ )}
380
+ {token.marketCap != null && token.marketCap > 0 && (
381
+ <div className="flex justify-between">
382
+ <span className="text-white/35">Market Cap</span>
383
+ <span className="text-white/70 font-mono">{formatUsd(token.marketCap)}</span>
384
+ </div>
385
+ )}
386
+ {token.lastTraded != null && (
387
+ <div className="flex justify-between">
388
+ <span className="text-white/35">Last traded</span>
389
+ <span className="text-white/70 font-mono">{formatTimeAgo(token.lastTraded)}</span>
390
+ </div>
391
+ )}
392
+ </div>
393
+
394
+ <div className="mt-2.5 pt-2 border-t border-white/[0.06] text-xs text-white/20 font-mono truncate">
395
+ {token.mint}
396
+ </div>
397
+ </div>
398
+ );
399
+ }
400
+
401
+ // --- Swap tooltip ---
402
+ function SwapTooltip({
403
+ address,
404
+ screenX,
405
+ screenY,
406
+ signature,
407
+ tokenIn,
408
+ tokenOut,
409
+ amountSol,
410
+ }: {
411
+ address: string;
412
+ screenX: number;
413
+ screenY: number;
414
+ signature?: string;
415
+ tokenIn?: string;
416
+ tokenOut?: string;
417
+ amountSol?: number;
418
+ }) {
419
+ const shortAddress = `${address.slice(0, 4)}...${address.slice(-4)}`;
420
+ const shortMint = (mint?: string) =>
421
+ mint ? `${mint.slice(0, 4)}...${mint.slice(-4)}` : "???";
422
+
423
+ const tooltipWidth = 240;
424
+ const tooltipHeight = 130;
425
+ let left = screenX + 16;
426
+ let top = screenY - 12;
427
+ if (typeof window !== "undefined") {
428
+ if (left + tooltipWidth > window.innerWidth - 8) left = screenX - tooltipWidth - 8;
429
+ if (top + tooltipHeight > window.innerHeight - 8) top = screenY - tooltipHeight;
430
+ if (top < 8) top = 8;
431
+ }
432
+
433
+ return (
434
+ <div
435
+ className="fixed z-50 bg-black/60 backdrop-blur-xl border border-white/[0.08] rounded-2xl p-3.5"
436
+ style={{ left, top, width: tooltipWidth, pointerEvents: "auto", cursor: "default" }}
437
+ >
438
+ <div className="flex items-center gap-2 mb-2">
439
+ <span className="text-xs text-orange-300/80 font-medium">SWAP</span>
440
+ <span className="font-mono text-xs text-purple-300/50">{shortAddress}</span>
441
+ </div>
442
+
443
+ <div className="flex items-center gap-1.5 text-sm text-white/70 mb-2">
444
+ <span className="font-mono text-xs">{shortMint(tokenIn)}</span>
445
+ <span className="text-white/30">&rarr;</span>
446
+ <span className="font-mono text-xs">{shortMint(tokenOut)}</span>
447
+ </div>
448
+
449
+ {amountSol != null && (
450
+ <div className="flex justify-between text-xs mb-2">
451
+ <span className="text-white/35">Amount</span>
452
+ <span className="text-white/70 font-mono">{formatNum(amountSol)} SOL</span>
453
+ </div>
454
+ )}
455
+
456
+ {signature && (
457
+ <a
458
+ href={`https://orbmarkets.io/tx/${signature}`}
459
+ target="_blank"
460
+ rel="noopener noreferrer"
461
+ className="block text-xs text-blue-400/60 hover:text-blue-400 transition-colors truncate cursor-pointer"
462
+ onClick={(e) => e.stopPropagation()}
463
+ >
464
+ View on Orb &rarr;
465
+ </a>
466
+ )}
467
+ </div>
468
+ );
469
+ }
470
+
471
+ export default function WindowTooltip(props: WindowHoverInfo) {
472
+ if (props.mode === "building") {
473
+ return (
474
+ <BuildingTooltip
475
+ address={props.address}
476
+ screenX={props.screenX}
477
+ screenY={props.screenY}
478
+ identityName={props.identityName}
479
+ identityType={props.identityType}
480
+ identityCategory={props.identityCategory}
481
+ />
482
+ );
483
+ }
484
+ if (props.mode === "swap") {
485
+ return (
486
+ <SwapTooltip
487
+ address={props.address}
488
+ screenX={props.screenX}
489
+ screenY={props.screenY}
490
+ signature={props.swapSignature}
491
+ tokenIn={props.swapTokenIn}
492
+ tokenOut={props.swapTokenOut}
493
+ amountSol={props.swapAmountSol}
494
+ />
495
+ );
496
+ }
497
+ return <TokenTooltip address={props.address} tokenIndex={props.tokenIndex} screenX={props.screenX} screenY={props.screenY} />;
498
+ }