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.
- package/README.md +518 -0
- package/bin/solanapolis.js +197 -0
- package/convex/_generated/api.d.ts +175 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/agent/conversation.ts +352 -0
- package/convex/agent/embeddingsCache.ts +110 -0
- package/convex/agent/memory.ts +450 -0
- package/convex/agent/schema.ts +53 -0
- package/convex/aiChat.ts +54 -0
- package/convex/aiTown/agent.ts +382 -0
- package/convex/aiTown/agentDescription.ts +27 -0
- package/convex/aiTown/agentInputs.ts +155 -0
- package/convex/aiTown/agentOperations.ts +178 -0
- package/convex/aiTown/conversation.ts +395 -0
- package/convex/aiTown/conversationMembership.ts +38 -0
- package/convex/aiTown/game.ts +371 -0
- package/convex/aiTown/ids.ts +32 -0
- package/convex/aiTown/inputHandler.ts +9 -0
- package/convex/aiTown/inputs.ts +25 -0
- package/convex/aiTown/insertInput.ts +20 -0
- package/convex/aiTown/location.ts +32 -0
- package/convex/aiTown/main.ts +154 -0
- package/convex/aiTown/movement.ts +189 -0
- package/convex/aiTown/player.ts +310 -0
- package/convex/aiTown/playerDescription.ts +35 -0
- package/convex/aiTown/schema.ts +79 -0
- package/convex/aiTown/world.ts +65 -0
- package/convex/aiTown/worldMap.ts +74 -0
- package/convex/chat.ts +79 -0
- package/convex/constants.ts +78 -0
- package/convex/convex.config.ts +6 -0
- package/convex/crons.ts +89 -0
- package/convex/engine/abstractGame.ts +199 -0
- package/convex/engine/historicalObject.ts +355 -0
- package/convex/engine/schema.ts +56 -0
- package/convex/http.ts +36 -0
- package/convex/init.ts +110 -0
- package/convex/messages.ts +53 -0
- package/convex/npcCarAgents.ts +415 -0
- package/convex/schema.ts +61 -0
- package/convex/streaming.ts +23 -0
- package/convex/testing.ts +202 -0
- package/convex/tsconfig.json +18 -0
- package/convex/util/FastIntegerCompression.ts +221 -0
- package/convex/util/assertNever.ts +4 -0
- package/convex/util/asyncMap.ts +20 -0
- package/convex/util/compression.ts +71 -0
- package/convex/util/geometry.ts +132 -0
- package/convex/util/isSimpleObject.ts +11 -0
- package/convex/util/llm.ts +724 -0
- package/convex/util/minheap.ts +38 -0
- package/convex/util/object.ts +22 -0
- package/convex/util/sleep.ts +3 -0
- package/convex/util/types.ts +33 -0
- package/convex/util/xxhash.ts +228 -0
- package/convex/world.ts +257 -0
- package/data/animations/campfire.json +45 -0
- package/data/animations/gentlesparkle.json +37 -0
- package/data/animations/gentlesplash.json +61 -0
- package/data/animations/gentlewaterfall.json +61 -0
- package/data/animations/windmill.json +78 -0
- package/data/characters.ts +121 -0
- package/data/convertMap.js +74 -0
- package/data/gentle.js +330 -0
- package/data/spritesheets/f1.ts +75 -0
- package/data/spritesheets/f2.ts +75 -0
- package/data/spritesheets/f3.ts +75 -0
- package/data/spritesheets/f4.ts +75 -0
- package/data/spritesheets/f5.ts +75 -0
- package/data/spritesheets/f6.ts +75 -0
- package/data/spritesheets/f7.ts +75 -0
- package/data/spritesheets/f8.ts +75 -0
- package/data/spritesheets/p1.ts +59 -0
- package/data/spritesheets/p2.ts +59 -0
- package/data/spritesheets/p3.ts +59 -0
- package/data/spritesheets/player.ts +59 -0
- package/data/spritesheets/types.ts +26 -0
- package/eslint.config.mjs +37 -0
- package/next.config.ts +7 -0
- package/package.json +85 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/helius-icon.svg +84 -0
- package/public/helius-logo.svg +85 -0
- package/public/next.svg +1 -0
- package/public/plane.glb +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/clear-city.ts +74 -0
- package/scripts/seed-wallets.ts +185 -0
- package/scripts/setup-webhook.ts +73 -0
- package/src/app/api/auth/callback/route.ts +6 -0
- package/src/app/api/auth/link-wallet/route.ts +6 -0
- package/src/app/api/auth/phantom/route.ts +6 -0
- package/src/app/api/broadcast-position/route.ts +59 -0
- package/src/app/api/leaderboard/route.ts +85 -0
- package/src/app/api/network-stats/route.ts +86 -0
- package/src/app/api/parcel-reward/route.ts +181 -0
- package/src/app/api/queue-status/route.ts +30 -0
- package/src/app/api/snapshots/route.ts +37 -0
- package/src/app/api/transactions/enhanced/route.ts +57 -0
- package/src/app/api/treasury/route.ts +83 -0
- package/src/app/api/wallet/[address]/balances/route.ts +124 -0
- package/src/app/api/wallet/[address]/identity/route.ts +32 -0
- package/src/app/api/wallet/[address]/route.ts +216 -0
- package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
- package/src/app/api/wallets/route.ts +68 -0
- package/src/app/api/webhooks/helius/route.ts +76 -0
- package/src/app/auth/callback/page.tsx +29 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +39 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/components/AITownNPCs.tsx +206 -0
- package/src/components/ActivityFeed.tsx +189 -0
- package/src/components/AuthPanel.tsx +163 -0
- package/src/components/BeachScene.tsx +280 -0
- package/src/components/Building.tsx +138 -0
- package/src/components/CesiumFlight.tsx +1768 -0
- package/src/components/CesiumGlobe.tsx +616 -0
- package/src/components/CitizenCard.tsx +442 -0
- package/src/components/CitizenCardModal.tsx +153 -0
- package/src/components/CityGrid.tsx +313 -0
- package/src/components/CityLandmarks.tsx +427 -0
- package/src/components/CityScene.tsx +1289 -0
- package/src/components/CitySlotsBadge.tsx +68 -0
- package/src/components/CockpitHUD.tsx +460 -0
- package/src/components/ConvexWrapper.tsx +19 -0
- package/src/components/DubaiDistrict.tsx +630 -0
- package/src/components/FlightMiniMap.tsx +133 -0
- package/src/components/GameChat.tsx +383 -0
- package/src/components/GameHUD.tsx +393 -0
- package/src/components/Ground.tsx +14 -0
- package/src/components/HowItWorksModal.tsx +251 -0
- package/src/components/IngestionBanner.tsx +123 -0
- package/src/components/InstancedBuildings.tsx +316 -0
- package/src/components/InstancedCars.tsx +504 -0
- package/src/components/InstancedCityPlanes.tsx +259 -0
- package/src/components/InstancedHouses.tsx +246 -0
- package/src/components/InstancedLampPosts.tsx +201 -0
- package/src/components/InstancedResidentCars.tsx +357 -0
- package/src/components/InstancedRoadDashes.tsx +42 -0
- package/src/components/InstancedSkyscrapers.tsx +434 -0
- package/src/components/InstancedTrees.tsx +67 -0
- package/src/components/LeaderboardPanel.tsx +136 -0
- package/src/components/MultiplayerPlanes.tsx +128 -0
- package/src/components/NetworkStats.tsx +83 -0
- package/src/components/NewBuildingSpotlight.tsx +93 -0
- package/src/components/ParcelChallengeBanner.tsx +242 -0
- package/src/components/ParcelReward.tsx +191 -0
- package/src/components/Park.tsx +42 -0
- package/src/components/PhantomWrapper.tsx +22 -0
- package/src/components/PixelStreamViewer.tsx +335 -0
- package/src/components/PlaneMode.tsx +190 -0
- package/src/components/PlayerCar.tsx +211 -0
- package/src/components/PlayerPlane.tsx +255 -0
- package/src/components/ProjectileRenderer.tsx +249 -0
- package/src/components/QueueStatusBanner.tsx +86 -0
- package/src/components/RealPlayerTags.tsx +82 -0
- package/src/components/SceneLighting.tsx +382 -0
- package/src/components/SelectionBeam.tsx +59 -0
- package/src/components/SwapPanel.tsx +104 -0
- package/src/components/SwapParticles.tsx +237 -0
- package/src/components/TreasureGate.tsx +505 -0
- package/src/components/WalletPanel.tsx +421 -0
- package/src/components/WalletSearch.tsx +244 -0
- package/src/components/WelcomeOverlay.tsx +135 -0
- package/src/components/WindowTooltip.tsx +498 -0
- package/src/context/AuthContext.tsx +230 -0
- package/src/lib/bot-detection.ts +125 -0
- package/src/lib/building-math.ts +136 -0
- package/src/lib/building-shader.ts +253 -0
- package/src/lib/car-paths.ts +244 -0
- package/src/lib/car-system.ts +182 -0
- package/src/lib/city-constants.ts +29 -0
- package/src/lib/city-slots.ts +35 -0
- package/src/lib/city-zoning.ts +64 -0
- package/src/lib/collision-map.ts +147 -0
- package/src/lib/day-night.ts +252 -0
- package/src/lib/export-card.ts +28 -0
- package/src/lib/helius-webhook.ts +90 -0
- package/src/lib/helius.ts +74 -0
- package/src/lib/house-shader.ts +119 -0
- package/src/lib/mock-data.ts +56 -0
- package/src/lib/multiplayer-manager.ts +329 -0
- package/src/lib/plane-physics.ts +66 -0
- package/src/lib/player-car.ts +147 -0
- package/src/lib/player-plane.ts +200 -0
- package/src/lib/projectile-system.ts +272 -0
- package/src/lib/skyscraper-types.ts +52 -0
- package/src/lib/sound-engine.ts +464 -0
- package/src/lib/supabase-admin.ts +9 -0
- package/src/lib/supabase.ts +8 -0
- package/src/lib/swap-events.ts +70 -0
- package/src/middleware.ts +37 -0
- package/src/types/phantom.d.ts +16 -0
- package/src/types/wallet.ts +20 -0
- 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
|
+
}
|