diff --git a/src/app/bitcoin-agents/[id]/page.tsx b/src/app/bitcoin-agents/[id]/page.tsx new file mode 100644 index 00000000..5994d438 --- /dev/null +++ b/src/app/bitcoin-agents/[id]/page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + LevelBadge, + HungerHealthBars, + XPProgress, + FeedButton, +} from "@/components/bitcoin-agents"; +import { + fetchBitcoinAgentById, + fetchFoodTiers, + fetchAgentCapabilities, +} from "@/services/bitcoin-agents.service"; +import type { BitcoinAgent, FoodTier, AgentCapabilities } from "@/types"; +import Link from "next/link"; + +const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api"; + +export default function BitcoinAgentDetailPage() { + const params = useParams(); + const agentId = parseInt(params.id as string, 10); + + const [agent, setAgent] = useState(null); + const [foodTiers, setFoodTiers] = useState>({}); + const [capabilities, setCapabilities] = useState( + null + ); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const loadData = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const [agentData, tiersData, capabilitiesData] = await Promise.all([ + fetchBitcoinAgentById(agentId), + fetchFoodTiers(), + fetchAgentCapabilities(agentId).catch(() => null), + ]); + + if (!agentData) { + setError("Agent not found"); + return; + } + + setAgent(agentData); + setFoodTiers(tiersData.food_tiers); + setCapabilities(capabilitiesData); + } catch (err) { + setError("Failed to load agent details"); + console.error(err); + } finally { + setIsLoading(false); + } + }, [agentId]); + + useEffect(() => { + if (!isNaN(agentId)) { + loadData(); + } + }, [agentId, loadData]); + + if (isLoading) { + return ( +
+
+ +
+ + + +
+
+ +
+ ); + } + + if (error || !agent) { + return ( +
+

+ {error || "Agent Not Found"} +

+ + + +
+ ); + } + + const faceUrl = + agent.face_image_url || + `${BITCOIN_FACES_API}/get-image?name=${agent.owner}`; + const hunger = agent.computed_hunger ?? agent.hunger; + const health = agent.computed_health ?? agent.health; + + return ( +
+ {/* Back Button */} + + ← Back to Agents + + + {/* Agent Header */} +
+
+ + + + {agent.name.slice(0, 2).toUpperCase()} + + + {!agent.alive && ( +
+ 💀 +
+ )} +
+ +
+
+

{agent.name}

+ +
+ +

+ Agent #{agent.agent_id} • Born at block {agent.birth_block} +

+ +
+ {!agent.alive && ( + + 💀 Deceased + + )} + + Fed {agent.total_fed_count} times + +
+
+
+ + {/* Status and Actions */} + {agent.alive && ( +
+ {/* Status Card */} + + + Status + + + + + {hunger <= 30 && ( +
+ ⚠️ {hunger <= 10 ? "Critical!" : "Warning:"} This agent is + hungry and needs food soon! +
+ )} +
+
+ + {/* XP Card */} + + + Experience + + +
+

{agent.xp.toLocaleString()}

+

Total XP

+
+ +
+
+
+ )} + + {/* Feed Button */} + {agent.alive && Object.keys(foodTiers).length > 0 && ( +
+ +
+ )} + + {/* Tabs for Details */} + + + Info + Capabilities + Activity + + + + + + Agent Information + + +
+
+

Owner

+

{agent.owner}

+
+
+

Agent ID

+

#{agent.agent_id}

+
+
+

Birth Block

+

{agent.birth_block}

+
+
+

Last Fed

+

Block {agent.last_fed}

+
+
+

Total Feedings

+

{agent.total_fed_count}

+
+
+

Status

+

{agent.alive ? "🟢 Alive" : "💀 Dead"}

+
+
+
+
+
+ + + + + Available Tools ({capabilities?.total_tools ?? 0}) + + + {capabilities ? ( +
+ {Object.entries(capabilities.tools_by_category).map( + ([category, tools]) => + tools.length > 0 && ( +
+

+ {category.replace(/_/g, " ")} +

+
+ {tools.map((tool) => ( + + {tool} + + ))} +
+
+ ) + )} +
+ ) : ( +

+ Capabilities not available +

+ )} +
+
+
+ + + + + Recent Activity + + +

+ Activity log coming soon... +

+
+
+
+
+ + {/* Death Certificate for Dead Agents */} + {!agent.alive && ( + + + 💀 Death Certificate + + +
+

{agent.name}

+

+ Lived {agent.last_fed - agent.birth_block} blocks +

+

+ Final XP: {agent.xp.toLocaleString()} +

+

+ Level: {agent.level.charAt(0).toUpperCase() + agent.level.slice(1)} +

+
+ + + +
+
+ )} +
+ ); +} diff --git a/src/app/bitcoin-agents/leaderboard/page.tsx b/src/app/bitcoin-agents/leaderboard/page.tsx new file mode 100644 index 00000000..cac9ed8d --- /dev/null +++ b/src/app/bitcoin-agents/leaderboard/page.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { LevelBadge } from "@/components/bitcoin-agents"; +import { fetchLeaderboard, fetchGlobalStats } from "@/services/bitcoin-agents.service"; +import type { BitcoinAgent, BitcoinAgentStats } from "@/types"; +import Link from "next/link"; + +const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api"; + +const RANK_ICONS = ["🥇", "🥈", "🥉"]; + +export default function LeaderboardPage() { + const [leaderboard, setLeaderboard] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadData(); + }, []); + + async function loadData() { + setIsLoading(true); + setError(null); + + try { + const [leaderboardData, statsData] = await Promise.all([ + fetchLeaderboard("mainnet", 25), + fetchGlobalStats(), + ]); + + setLeaderboard(leaderboardData.leaderboard); + setStats(statsData); + } catch (err) { + setError("Failed to load leaderboard"); + console.error(err); + } finally { + setIsLoading(false); + } + } + + return ( +
+ {/* Header */} +
+

🏆 Leaderboard

+

+ Top Bitcoin Agents by experience points +

+
+ + {/* Stats Summary */} + {stats && ( +
+ + +

{stats.total_agents}

+

Total Agents

+
+
+ + +

+ {stats.alive_count} +

+

Still Alive

+
+
+ + +

{stats.total_feedings}

+

Total Feedings

+
+
+
+ )} + + + + ⭐ By XP + 📅 Longevity + 🍖 Top Feeders + + + + + + Top Agents by XP + + + {isLoading && ( +
+ {[...Array(5)].map((_, i) => ( +
+ + +
+ + +
+ +
+ ))} +
+ )} + + {error && ( +

{error}

+ )} + + {!isLoading && !error && ( +
+ {leaderboard.length === 0 ? ( +

+ No agents yet. Be the first to mint one! +

+ ) : ( + leaderboard.map((agent, index) => ( + + {/* Rank */} +
+ {index < 3 ? ( + + {RANK_ICONS[index]} + + ) : ( + + #{index + 1} + + )} +
+ + {/* Avatar */} + + + + {agent.name.slice(0, 2).toUpperCase()} + + + + {/* Info */} +
+

{agent.name}

+
+ + {!agent.alive && ( + 💀 + )} +
+
+ + {/* XP */} +
+

+ {agent.xp.toLocaleString()} +

+

XP

+
+ + )) + )} +
+ )} +
+
+
+ + + + + Longest Living Agents + + +

+ Longevity leaderboard coming soon... +

+
+
+
+ + + + + Most Active Feeders + + +

+ Top feeders leaderboard coming soon... +

+
+
+
+
+ + {/* Evolution Milestones */} + + + 🎯 Evolution Milestones + + +
+
+

🥚

+

Hatchling

+

0 XP

+
+
+

🐣

+

Junior

+

500 XP

+
+
+

🐥

+

Senior

+

2,000 XP

+
+
+

🦅

+

Elder

+

10,000 XP

+
+
+

🔥

+

Legendary

+

50,000 XP

+
+
+
+
+ + {/* Back Link */} +
+ + ← Back to Agents + +
+
+ ); +} diff --git a/src/app/bitcoin-agents/mint/page.tsx b/src/app/bitcoin-agents/mint/page.tsx new file mode 100644 index 00000000..a0e2382e --- /dev/null +++ b/src/app/bitcoin-agents/mint/page.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { requestMintAgent, fetchFoodTiers } from "@/services/bitcoin-agents.service"; +import Link from "next/link"; + +const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api"; + +export default function MintAgentPage() { + const [name, setName] = useState(""); + const [previewSeed, setPreviewSeed] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [mintCost, setMintCost] = useState(10000); + const [paymentInfo, setPaymentInfo] = useState<{ + cost_sats: number; + payment_address: string; + message: string; + } | null>(null); + const [error, setError] = useState(null); + + // Generate a preview seed when name changes + useEffect(() => { + if (name.length >= 1) { + // Use name + timestamp for unique preview + setPreviewSeed(`preview-${name}-${Date.now()}`); + } + }, [name]); + + // Fetch mint cost + useEffect(() => { + fetchFoodTiers() + .then((data) => setMintCost(data.mint_cost)) + .catch(console.error); + }, []); + + const handleMint = async () => { + if (!name.trim()) { + setError("Please enter a name for your agent"); + return; + } + + if (name.length > 64) { + setError("Name must be 64 characters or less"); + return; + } + + setIsLoading(true); + setError(null); + + try { + const result = await requestMintAgent(name.trim()); + setPaymentInfo({ + cost_sats: result.cost_sats, + payment_address: result.payment_address, + message: result.message, + }); + } catch (err) { + setError("Failed to initiate mint. Please try again."); + console.error(err); + } finally { + setIsLoading(false); + } + }; + + const facePreviewUrl = previewSeed + ? `${BITCOIN_FACES_API}/get-image?name=${previewSeed}` + : null; + + return ( +
+ {/* Back Button */} + + ← Back to Agents + + +
+

🥚 Mint Your Bitcoin Agent

+

+ Create a unique AI companion that lives on Bitcoin +

+
+ + {!paymentInfo ? ( + + + Create Your Agent + + + {/* Preview */} +
+ + {facePreviewUrl ? ( + + ) : null} + + {name ? name.slice(0, 2).toUpperCase() : "🥚"} + + +

+ Your agent's unique Bitcoin Face +

+
+ + {/* Name Input */} +
+ + setName(e.target.value)} + maxLength={64} + /> +

+ {name.length}/64 characters +

+
+ + {/* Cost Breakdown */} +
+

Mint Cost

+
+ Agent NFT + + {mintCost.toLocaleString()} sats + +
+
+ Total + + {mintCost.toLocaleString()} sats + +
+
+ + {/* What You Get */} +
+

What you get:

+
    +
  • ✓ Unique Bitcoin Face avatar
  • +
  • ✓ On-chain AI agent identity
  • +
  • ✓ Starting level: Hatchling
  • +
  • ✓ 100% hunger and health
  • +
  • ✓ Access to read-only MCP tools
  • +
+
+ + {/* Warning */} +
+

⚠️ Important

+

+ Bitcoin Agents require regular feeding to survive. If you + neglect your agent, it will die permanently. Death is real + and cannot be reversed. +

+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Mint Button */} + +
+
+ ) : ( + + + Complete Payment + + + {/* Payment Details */} +
+

+ Send exactly this amount: +

+

+ {paymentInfo.cost_sats.toLocaleString()} sats +

+
+ +
+

+ To this address: +

+ + {paymentInfo.payment_address} + +
+ +

+ {paymentInfo.message} +

+ + {/* QR Code placeholder */} +
+
+

QR Code

+
+
+ + {/* Actions */} +
+ + +
+ +

+ After payment is confirmed on-chain, your agent will appear in + your collection. This may take a few minutes. +

+
+
+ )} + + {/* Info Cards */} +
+ + +

🍖 Feeding

+

+ Feed your agent regularly to keep it alive. Premium food gives + more XP! +

+
+
+ + +

⭐ Evolution

+

+ Earn XP to evolve from Hatchling to Legendary. Higher levels + unlock more tools. +

+
+
+
+
+ ); +} diff --git a/src/app/bitcoin-agents/page.tsx b/src/app/bitcoin-agents/page.tsx new file mode 100644 index 00000000..3931d913 --- /dev/null +++ b/src/app/bitcoin-agents/page.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { BitcoinAgentCard } from "@/components/bitcoin-agents"; +import { + fetchBitcoinAgents, + fetchGlobalStats, +} from "@/services/bitcoin-agents.service"; +import type { + BitcoinAgent, + BitcoinAgentLevel, + BitcoinAgentStats, +} from "@/types"; +import Link from "next/link"; + +export default function BitcoinAgentsPage() { + const [agents, setAgents] = useState([]); + const [stats, setStats] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Filters + const [statusFilter, setStatusFilter] = useState<"all" | "alive" | "dead">( + "alive" + ); + const [levelFilter, setLevelFilter] = useState( + "all" + ); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState<"xp" | "hunger" | "age">("xp"); + + const loadData = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const [agentsData, statsData] = await Promise.all([ + fetchBitcoinAgents({ + status: statusFilter === "all" ? undefined : statusFilter, + level: levelFilter === "all" ? undefined : levelFilter, + }), + fetchGlobalStats(), + ]); + + setAgents(agentsData.agents); + setStats(statsData); + } catch (err) { + setError("Failed to load agents. Please try again."); + console.error(err); + } finally { + setIsLoading(false); + } + }, [statusFilter, levelFilter]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // Filter and sort agents + const filteredAgents = agents + .filter((agent) => { + if (searchQuery) { + const query = searchQuery.toLowerCase(); + return ( + agent.name.toLowerCase().includes(query) || + agent.agent_id.toString().includes(query) || + agent.owner.toLowerCase().includes(query) + ); + } + return true; + }) + .sort((a, b) => { + if (sortBy === "xp") return b.xp - a.xp; + if (sortBy === "hunger") + return (a.computed_hunger ?? a.hunger) - (b.computed_hunger ?? b.hunger); + if (sortBy === "age") return a.birth_block - b.birth_block; + return 0; + }); + + return ( +
+ {/* Header */} +
+
+

Bitcoin Agents

+

+ Tamagotchi-style AI companions on Bitcoin +

+
+ + + +
+ + {/* Stats Cards */} + {stats && ( +
+ + + + Total Agents + + + +

{stats.total_agents}

+
+
+ + + + Alive + + + +

+ {stats.alive_count} +

+
+
+ + + + Deaths + + + +

+ {stats.total_deaths} +

+
+
+ + + + Total Feedings + + + +

{stats.total_feedings}

+
+
+
+ )} + + {/* Filters */} +
+ setSearchQuery(e.target.value)} + className="md:w-64" + /> + + + + + + +
+ + {/* Quick Links */} +
+ + + + + + +
+ + {/* Error State */} + {error && ( +
+

{error}

+ +
+ )} + + {/* Loading State */} + {isLoading && ( +
+ {[...Array(8)].map((_, i) => ( + + +
+ +
+ + +
+
+
+ + + + +
+ ))} +
+ )} + + {/* Agents Grid */} + {!isLoading && !error && ( + <> + {filteredAgents.length === 0 ? ( +
+

+ No agents found matching your criteria. +

+ + + +
+ ) : ( +
+ {filteredAgents.map((agent) => ( + + ))} +
+ )} + + )} +
+ ); +} diff --git a/src/app/graveyard/page.tsx b/src/app/graveyard/page.tsx new file mode 100644 index 00000000..c5a3b031 --- /dev/null +++ b/src/app/graveyard/page.tsx @@ -0,0 +1,224 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { LevelBadge } from "@/components/bitcoin-agents"; +import { fetchGraveyard } from "@/services/bitcoin-agents.service"; +import type { DeathCertificate } from "@/types"; +import Link from "next/link"; + +const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api"; + +export default function GraveyardPage() { + const [certificates, setCertificates] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [sortBy, setSortBy] = useState<"recent" | "lifespan" | "level">( + "recent" + ); + + useEffect(() => { + loadData(); + }, []); + + async function loadData() { + setIsLoading(true); + setError(null); + + try { + const data = await fetchGraveyard(); + setCertificates(data.certificates); + } catch (err) { + setError("Failed to load graveyard"); + console.error(err); + } finally { + setIsLoading(false); + } + } + + const sortedCertificates = [...certificates].sort((a, b) => { + if (sortBy === "recent") return b.death_block - a.death_block; + if (sortBy === "lifespan") return b.lifespan_blocks - a.lifespan_blocks; + if (sortBy === "level") return b.final_xp - a.final_xp; + return 0; + }); + + return ( +
+ {/* Header */} +
+

💀 The Graveyard

+

+ A memorial for fallen Bitcoin Agents +

+
+ + {/* Sort */} +
+

+ {certificates.length} agents at rest +

+ +
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Loading */} + {isLoading && ( +
+ {[...Array(6)].map((_, i) => ( + + +
+ +
+ + +
+
+ +
+
+ ))} +
+ )} + + {/* Certificates */} + {!isLoading && !error && ( + <> + {sortedCertificates.length === 0 ? ( +
+

🌱

+

+ The graveyard is empty +

+

+ All agents are still alive! Keep feeding them. +

+ + + View Living Agents → + + +
+ ) : ( +
+ {sortedCertificates.map((cert) => ( + + + {/* Header */} +
+
+ + + + {cert.name.slice(0, 2).toUpperCase()} + + + + 💀 + +
+
+

{cert.name}

+

+ Agent #{cert.agent_id} +

+ +
+
+ + {/* Stats */} +
+
+
+

Lifespan

+

+ {cert.lifespan_blocks.toLocaleString()} blocks +

+
+
+

Final XP

+

+ {cert.final_xp.toLocaleString()} +

+
+
+

Total Feedings

+

{cert.total_feedings}

+
+
+

Cause

+

{cert.cause_of_death}

+
+
+
+ + {/* Epitaph */} + {cert.epitaph && ( +
+

+ "{cert.epitaph}" +

+
+ )} + + {/* Death Block */} +

+ Died at block {cert.death_block.toLocaleString()} +

+
+
+ ))} +
+ )} + + )} + + {/* Back Link */} +
+ + ← Back to Living Agents + +
+
+ ); +} diff --git a/src/components/bitcoin-agents/BitcoinAgentCard.tsx b/src/components/bitcoin-agents/BitcoinAgentCard.tsx new file mode 100644 index 00000000..e3a1a500 --- /dev/null +++ b/src/components/bitcoin-agents/BitcoinAgentCard.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import type { BitcoinAgent } from "@/types"; +import { LevelBadge } from "./LevelBadge"; +import { HungerHealthBars } from "./HungerHealthBars"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; + +interface BitcoinAgentCardProps { + agent: BitcoinAgent; + showOwner?: boolean; + showStats?: boolean; + className?: string; +} + +const BITCOIN_FACES_API = "https://bitcoinfaces.xyz/api"; + +export function BitcoinAgentCard({ + agent, + showOwner = false, + showStats = true, + className, +}: BitcoinAgentCardProps) { + const faceUrl = + agent.face_image_url || + `${BITCOIN_FACES_API}/get-image?name=${agent.owner}`; + + const hunger = agent.computed_hunger ?? agent.hunger; + const health = agent.computed_health ?? agent.health; + + return ( + + + +
+
+ + + + {agent.name.slice(0, 2).toUpperCase()} + + +
+

+ {agent.name} +

+

+ #{agent.agent_id} +

+
+
+
+ + {!agent.alive && ( + + 💀 Dead + + )} +
+
+
+ + {showStats && agent.alive && ( + + +
+ 🍖 {hunger}% + ❤️ {health}% + ⭐ {agent.xp.toLocaleString()} XP +
+
+ )} + + {showOwner && ( + +

+ Owner: {agent.owner.slice(0, 8)}...{agent.owner.slice(-4)} +

+
+ )} +
+ + ); +} diff --git a/src/components/bitcoin-agents/FeedButton.tsx b/src/components/bitcoin-agents/FeedButton.tsx new file mode 100644 index 00000000..846681a4 --- /dev/null +++ b/src/components/bitcoin-agents/FeedButton.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { requestFeedAgent } from "@/services/bitcoin-agents.service"; +import type { FoodTier } from "@/types"; + +interface FeedButtonProps { + agentId: number; + agentName: string; + foodTiers: Record; + disabled?: boolean; + onFeedRequested?: (paymentDetails: { + cost_sats: number; + payment_address: string; + }) => void; +} + +export function FeedButton({ + agentId, + agentName, + foodTiers, + disabled = false, + onFeedRequested, +}: FeedButtonProps) { + const [isOpen, setIsOpen] = useState(false); + const [selectedTier, setSelectedTier] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [paymentInfo, setPaymentInfo] = useState<{ + cost_sats: number; + payment_address: string; + message: string; + } | null>(null); + + const handleFeed = async (tier: number) => { + setSelectedTier(tier); + setIsLoading(true); + setError(null); + + try { + const result = await requestFeedAgent(agentId, tier); + setPaymentInfo({ + cost_sats: result.cost_sats, + payment_address: result.payment_address, + message: result.message, + }); + onFeedRequested?.({ + cost_sats: result.cost_sats, + payment_address: result.payment_address, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to request feed. Please try again."; + setError(message); + console.error("Failed to request feed:", err); + } finally { + setIsLoading(false); + } + }; + + const resetState = () => { + setPaymentInfo(null); + setSelectedTier(null); + setError(null); + }; + + const tiers = Object.entries(foodTiers).map(([key, value]) => ({ + ...value, + tier: parseInt(key), + })); + + return ( + + + + + + + Feed {agentName} + + Choose a food tier. Higher tiers give more XP! + + + + {error && ( +
+ {error} +
+ )} + + {!paymentInfo ? ( +
+ {tiers.map((tier) => ( + + ))} +
+ ) : ( +
+
+

+ Send payment to: +

+ + {paymentInfo.payment_address} + +

+ {paymentInfo.cost_sats.toLocaleString()} sats +

+
+

+ {paymentInfo.message} +

+ +
+ )} +
+
+ ); +} diff --git a/src/components/bitcoin-agents/HungerHealthBars.tsx b/src/components/bitcoin-agents/HungerHealthBars.tsx new file mode 100644 index 00000000..4d1cc037 --- /dev/null +++ b/src/components/bitcoin-agents/HungerHealthBars.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { Progress } from "@/components/ui/progress"; +import { cn } from "@/lib/utils"; + +interface HungerHealthBarsProps { + hunger: number; + health: number; + size?: "sm" | "md" | "lg"; + showLabels?: boolean; + showValues?: boolean; + className?: string; +} + +const SIZE_CLASSES = { + sm: "h-1.5", + md: "h-2", + lg: "h-3", +}; + +function getBarColor(value: number, type: "hunger" | "health"): string { + if (value <= 10) return "bg-red-500"; + if (value <= 30) return "bg-orange-500"; + if (value <= 50) return "bg-yellow-500"; + return type === "hunger" ? "bg-green-500" : "bg-blue-500"; +} + +function getStatusText(value: number): string { + if (value <= 10) return "Critical!"; + if (value <= 30) return "Low"; + if (value <= 50) return "Moderate"; + if (value <= 80) return "Good"; + return "Full"; +} + +export function HungerHealthBars({ + hunger, + health, + size = "md", + showLabels = true, + showValues = true, + className, +}: HungerHealthBarsProps) { + return ( +
+ {/* Hunger Bar */} +
+ {showLabels && ( +
+ + 🍖 Hunger + + {showValues && ( + + {hunger}% - {getStatusText(hunger)} + + )} +
+ )} +
+ +
+
+
+ + {/* Health Bar */} +
+ {showLabels && ( +
+ + ❤️ Health + + {showValues && ( + + {health}% - {getStatusText(health)} + + )} +
+ )} +
+ +
+
+
+
+ ); +} diff --git a/src/components/bitcoin-agents/HungryAgentBanner.tsx b/src/components/bitcoin-agents/HungryAgentBanner.tsx new file mode 100644 index 00000000..7a98608e --- /dev/null +++ b/src/components/bitcoin-agents/HungryAgentBanner.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { X } from "lucide-react"; +import { fetchBitcoinAgents } from "@/services/bitcoin-agents.service"; +import type { BitcoinAgent } from "@/types"; +import Link from "next/link"; + +// Hunger thresholds +const HUNGER_WARNING_THRESHOLD = 30; +const HUNGER_CRITICAL_THRESHOLD = 10; + +interface HungryAgentBannerProps { + ownerAddress?: string; +} + +export function HungryAgentBanner({ ownerAddress }: HungryAgentBannerProps) { + const [hungryAgents, setHungryAgents] = useState([]); + const [criticalAgents, setCriticalAgents] = useState([]); + const [isDismissed, setIsDismissed] = useState(false); + + const loadAgents = useCallback(async () => { + if (!ownerAddress) return; + + try { + const { agents } = await fetchBitcoinAgents({ owner: ownerAddress, status: "alive" }); + + const hungry = agents.filter((a) => { + const hunger = a.computed_hunger ?? a.hunger; + return hunger <= HUNGER_WARNING_THRESHOLD && hunger > HUNGER_CRITICAL_THRESHOLD; + }); + + const critical = agents.filter((a) => { + const hunger = a.computed_hunger ?? a.hunger; + return hunger <= HUNGER_CRITICAL_THRESHOLD; + }); + + setHungryAgents(hungry); + setCriticalAgents(critical); + } catch (error) { + // Silently fail - banner is non-critical + console.error("Failed to check agent status:", error); + } + }, [ownerAddress]); + + useEffect(() => { + if (!ownerAddress) return; + + // Load immediately + loadAgents(); + + // Check every 5 minutes + const interval = setInterval(loadAgents, 5 * 60 * 1000); + + return () => clearInterval(interval); + }, [ownerAddress, loadAgents]); + + if (isDismissed || (hungryAgents.length === 0 && criticalAgents.length === 0)) { + return null; + } + + const hasCritical = criticalAgents.length > 0; + const totalCount = hungryAgents.length + criticalAgents.length; + + return ( + + + + + {hasCritical ? "🚨 Critical Alert!" : "⚠️ Hungry Agents"} + + + + {hasCritical ? ( + <> + + {criticalAgents.length} agent + {criticalAgents.length > 1 ? "s" : ""} in critical condition! + {" "} + Feed them immediately or they will die. + {hungryAgents.length > 0 && ( + + ({hungryAgents.length} other{hungryAgents.length > 1 ? "s" : ""}{" "} + also hungry) + + )} + + ) : ( + <> + {totalCount} of your agent{totalCount > 1 ? "s" : ""}{" "} + {totalCount > 1 ? "are" : "is"} getting hungry. Feed them soon! + + )} + +
+ {criticalAgents.slice(0, 3).map((agent) => ( + + + + ))} + {hungryAgents.slice(0, 3 - criticalAgents.length).map((agent) => ( + + + + ))} + {totalCount > 3 && ( + + + + )} +
+
+
+ ); +} diff --git a/src/components/bitcoin-agents/LevelBadge.tsx b/src/components/bitcoin-agents/LevelBadge.tsx new file mode 100644 index 00000000..6c4fc0d7 --- /dev/null +++ b/src/components/bitcoin-agents/LevelBadge.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import type { BitcoinAgentLevel } from "@/types"; +import { cn } from "@/lib/utils"; + +interface LevelBadgeProps { + level: BitcoinAgentLevel; + xp?: number; + size?: "sm" | "md" | "lg"; + showXp?: boolean; +} + +const LEVEL_STYLES: Record = { + hatchling: "bg-gray-100 text-gray-800 border-gray-300", + junior: "bg-green-100 text-green-800 border-green-300", + senior: "bg-blue-100 text-blue-800 border-blue-300", + elder: "bg-purple-100 text-purple-800 border-purple-300", + legendary: + "bg-gradient-to-r from-yellow-100 to-orange-100 text-yellow-800 border-yellow-400", +}; + +const LEVEL_ICONS: Record = { + hatchling: "🥚", + junior: "🐣", + senior: "🐥", + elder: "🦅", + legendary: "🔥", +}; + +const SIZE_CLASSES = { + sm: "text-xs px-1.5 py-0.5", + md: "text-sm px-2 py-1", + lg: "text-base px-3 py-1.5", +}; + +export function LevelBadge({ + level, + xp, + size = "md", + showXp = false, +}: LevelBadgeProps) { + const levelName = level.charAt(0).toUpperCase() + level.slice(1); + + return ( + + {LEVEL_ICONS[level]} + {levelName} + {showXp && xp !== undefined && ( + ({xp.toLocaleString()} XP) + )} + + ); +} diff --git a/src/components/bitcoin-agents/XPProgress.tsx b/src/components/bitcoin-agents/XPProgress.tsx new file mode 100644 index 00000000..019aea4d --- /dev/null +++ b/src/components/bitcoin-agents/XPProgress.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { Progress } from "@/components/ui/progress"; +import type { BitcoinAgentLevel } from "@/types"; +import { XP_THRESHOLDS } from "@/types"; +import { cn } from "@/lib/utils"; + +interface XPProgressProps { + xp: number; + level: BitcoinAgentLevel; + showNumbers?: boolean; + className?: string; +} + +const LEVEL_ORDER: BitcoinAgentLevel[] = [ + "hatchling", + "junior", + "senior", + "elder", + "legendary", +]; + +export function XPProgress({ + xp, + level, + showNumbers = true, + className, +}: XPProgressProps) { + const currentLevelIndex = LEVEL_ORDER.indexOf(level); + const nextLevel = currentLevelIndex < LEVEL_ORDER.length - 1 + ? LEVEL_ORDER[currentLevelIndex + 1] + : undefined; + const isMaxLevel = level === "legendary"; + + const currentThreshold = XP_THRESHOLDS[level] ?? 0; + const nextThreshold = nextLevel ? XP_THRESHOLDS[nextLevel] : currentThreshold; + + const xpInCurrentLevel = Math.max(0, xp - currentThreshold); + const xpNeededForNext = Math.max(1, nextThreshold - currentThreshold); // Prevent division by zero + const progress = isMaxLevel ? 100 : Math.min((xpInCurrentLevel / xpNeededForNext) * 100, 100); + + return ( +
+ {showNumbers && ( +
+ {xp.toLocaleString()} XP + {!isMaxLevel && ( + + {nextThreshold.toLocaleString()} XP to{" "} + {nextLevel?.charAt(0).toUpperCase()} + {nextLevel?.slice(1)} + + )} + {isMaxLevel && Max Level!} +
+ )} + + {!isMaxLevel && showNumbers && ( +

+ {(nextThreshold - xp).toLocaleString()} XP to next level +

+ )} +
+ ); +} diff --git a/src/components/bitcoin-agents/index.ts b/src/components/bitcoin-agents/index.ts new file mode 100644 index 00000000..68a0f4e1 --- /dev/null +++ b/src/components/bitcoin-agents/index.ts @@ -0,0 +1,6 @@ +export { BitcoinAgentCard } from "./BitcoinAgentCard"; +export { FeedButton } from "./FeedButton"; +export { HungryAgentBanner } from "./HungryAgentBanner"; +export { HungerHealthBars } from "./HungerHealthBars"; +export { LevelBadge } from "./LevelBadge"; +export { XPProgress } from "./XPProgress"; diff --git a/src/services/bitcoin-agents.service.ts b/src/services/bitcoin-agents.service.ts new file mode 100644 index 00000000..cb15de1c --- /dev/null +++ b/src/services/bitcoin-agents.service.ts @@ -0,0 +1,313 @@ +/** + * Bitcoin Agents API service + * + * Calls the aibtcdev-backend Bitcoin Agents endpoints + */ + +import type { + BitcoinAgent, + BitcoinAgentFilter, + BitcoinAgentStats, + DeathCertificate, + FoodTier, + TierInfo, + AgentCapabilities, +} from "@/types"; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL || "https://api.aibtc.dev"; + +/** + * Fetch all Bitcoin Agents with optional filters + */ +export async function fetchBitcoinAgents( + filter?: BitcoinAgentFilter, + limit = 50, + offset = 0 +): Promise<{ agents: BitcoinAgent[]; total: number }> { + const params = new URLSearchParams(); + + if (filter?.owner) params.set("owner", filter.owner); + if (filter?.status) params.set("status", filter.status); + if (filter?.level) params.set("level", filter.level); + if (filter?.network) params.set("network", filter.network); + params.set("limit", limit.toString()); + params.set("offset", offset.toString()); + + const response = await fetch( + `${API_BASE}/bitcoin-agents?${params.toString()}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch agents: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch a single Bitcoin Agent by ID + */ +export async function fetchBitcoinAgentById( + agentId: number, + network = "mainnet" +): Promise { + const response = await fetch( + `${API_BASE}/bitcoin-agents/${agentId}?network=${network}` + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`Failed to fetch agent: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch agent's computed status (current hunger/health) + */ +export async function fetchAgentStatus( + agentId: number, + network = "mainnet" +): Promise<{ hunger: number; health: number; alive: boolean } | null> { + const response = await fetch( + `${API_BASE}/bitcoin-agents/${agentId}/status?network=${network}` + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`Failed to fetch agent status: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch leaderboard (top agents by XP) + */ +export async function fetchLeaderboard( + network = "mainnet", + limit = 10 +): Promise<{ leaderboard: BitcoinAgent[] }> { + const response = await fetch( + `${API_BASE}/bitcoin-agents/leaderboard?network=${network}&limit=${limit}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch leaderboard: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch graveyard (dead agents) + */ +export async function fetchGraveyard( + network = "mainnet", + limit = 50, + offset = 0 +): Promise<{ certificates: DeathCertificate[]; total: number }> { + const response = await fetch( + `${API_BASE}/bitcoin-agents/graveyard?network=${network}&limit=${limit}&offset=${offset}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch graveyard: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch global statistics + */ +export async function fetchGlobalStats( + network = "mainnet" +): Promise { + const response = await fetch( + `${API_BASE}/bitcoin-agents/stats?network=${network}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch stats: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch food tier pricing + */ +export async function fetchFoodTiers(): Promise<{ + food_tiers: Record; + mint_cost: number; +}> { + const response = await fetch(`${API_BASE}/bitcoin-agents/food-tiers`); + + if (!response.ok) { + throw new Error(`Failed to fetch food tiers: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch tier info (evolution levels) + */ +export async function fetchTierInfo(): Promise<{ tiers: TierInfo[] }> { + const response = await fetch(`${API_BASE}/bitcoin-agents/tier-info`); + + if (!response.ok) { + throw new Error(`Failed to fetch tier info: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch agent capabilities (available tools) + */ +export async function fetchAgentCapabilities( + agentId: number, + network = "mainnet" +): Promise { + const response = await fetch( + `${API_BASE}/bitcoin-agents/${agentId}/capabilities?network=${network}` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch capabilities: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Request to mint a new agent (returns 402 with payment details) + */ +export async function requestMintAgent( + name: string, + network = "mainnet" +): Promise<{ + status: string; + action: string; + name: string; + cost_sats: number; + payment_address: string; + message: string; +}> { + const response = await fetch( + `${API_BASE}/bitcoin-agents/mint?name=${encodeURIComponent(name)}&network=${network}`, + { method: "POST" } + ); + + // 402 is expected - it contains payment details + if (response.status !== 402) { + throw new Error(`Unexpected response: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Request to feed an agent (returns 402 with payment details) + */ +export async function requestFeedAgent( + agentId: number, + foodTier: number, + network = "mainnet" +): Promise<{ + status: string; + action: string; + agent_id: number; + food_tier: number; + food_name: string; + cost_sats: number; + xp_reward: number; + payment_address: string; + message: string; +}> { + const response = await fetch( + `${API_BASE}/bitcoin-agents/${agentId}/feed?food_tier=${foodTier}&network=${network}`, + { method: "POST" } + ); + + // 402 is expected - it contains payment details + if (response.status !== 402) { + throw new Error(`Unexpected response: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch death certificate for a dead agent + */ +export async function fetchDeathCertificate( + agentId: number, + network = "mainnet" +): Promise { + const response = await fetch( + `${API_BASE}/bitcoin-agents/${agentId}/death-certificate?network=${network}` + ); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error( + `Failed to fetch death certificate: ${response.statusText}` + ); + } + + return response.json(); +} + +/** + * Execute an agent visit (social interaction) + */ +export async function visitAgent( + visitorId: number, + hostId: number, + network = "mainnet" +): Promise<{ + success: boolean; + visitor_xp_earned?: number; + host_xp_earned?: number; + message?: string; + error?: string; +}> { + const response = await fetch( + `${API_BASE}/bitcoin-agents/${visitorId}/visit/${hostId}?network=${network}`, + { method: "POST" } + ); + + return response.json(); +} + +/** + * Check if an agent should die + */ +export async function checkAgentDeath( + agentId: number, + network = "mainnet" +): Promise<{ agent_id: number; died: boolean; message: string }> { + const response = await fetch( + `${API_BASE}/bitcoin-agents/${agentId}/check-death?network=${network}`, + { method: "POST" } + ); + + if (!response.ok) { + throw new Error(`Failed to check death: ${response.statusText}`); + } + + return response.json(); +} diff --git a/src/services/index.ts b/src/services/index.ts index 0cbec718..8b862a89 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -5,6 +5,7 @@ export * from "./supabase"; export * from "./agent.service"; export * from "./agent-prompt.service"; export * from "./airdrop.service"; +export * from "./bitcoin-agents.service"; export * from "./chain-state.service"; export * from "./dao.service"; export * from "./job.service"; diff --git a/src/types/bitcoin-agent.ts b/src/types/bitcoin-agent.ts new file mode 100644 index 00000000..9fda79c1 --- /dev/null +++ b/src/types/bitcoin-agent.ts @@ -0,0 +1,126 @@ +/** + * Bitcoin Agent types for Tamagotchi-style on-chain AI agents + */ + +export type BitcoinAgentLevel = + | "hatchling" + | "junior" + | "senior" + | "elder" + | "legendary"; + +export type BitcoinAgentStatus = "alive" | "dead"; + +export interface BitcoinAgent { + agent_id: number; + owner: string; + name: string; + hunger: number; + health: number; + xp: number; + level: BitcoinAgentLevel; + birth_block: number; + last_fed: number; + total_fed_count: number; + alive: boolean; + // Computed state + computed_hunger?: number; + computed_health?: number; + // Face data + face_svg_url?: string; + face_image_url?: string; + // Timestamps + created_at?: string; + updated_at?: string; +} + +export interface DeathCertificate { + agent_id: number; + name: string; + owner: string; + death_block: number; + birth_block: number; + final_xp: number; + final_level: BitcoinAgentLevel; + total_feedings: number; + epitaph?: string; + cause_of_death: "starvation" | "neglect"; + lifespan_blocks: number; +} + +export interface BitcoinAgentFilter { + owner?: string; + status?: BitcoinAgentStatus; + level?: BitcoinAgentLevel; + network?: "mainnet" | "testnet"; +} + +export interface FoodTier { + tier: number; + name: string; + cost: number; + xp: number; +} + +export interface TierInfo { + level: number; + name: string; + xp_required: number; + tools_count: number; + new_capabilities: string[]; +} + +export interface BitcoinAgentStats { + total_agents: number; + alive_count: number; + total_deaths: number; + total_feedings: number; + network: string; +} + +export interface AgentCapabilities { + agent_id: number; + level: number; + level_name: string; + xp: number; + total_tools: number; + tools_by_category: { + read_only: string[]; + transfers: string[]; + trading: string[]; + dao: string[]; + social: string[]; + advanced: string[]; + }; + all_tools: string[]; +} + +// XP thresholds for levels +export const XP_THRESHOLDS: Record = { + hatchling: 0, + junior: 500, + senior: 2000, + elder: 10000, + legendary: 50000, +}; + +// Level colors for UI +export const LEVEL_COLORS: Record = { + hatchling: "bg-gray-500", + junior: "bg-green-500", + senior: "bg-blue-500", + elder: "bg-purple-500", + legendary: "bg-yellow-500", +}; + +// Level badge variants +export const LEVEL_BADGE_VARIANTS: Record< + BitcoinAgentLevel, + "default" | "secondary" | "destructive" | "outline" +> = { + hatchling: "secondary", + junior: "default", + senior: "default", + elder: "default", + legendary: "default", +}; diff --git a/src/types/index.ts b/src/types/index.ts index d0a3159e..e1283dba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ export * from "./crew"; export * from "./user"; export * from "./common"; export * from "./airdrop"; +export * from "./bitcoin-agent"; // Re-export only Message from chat types to avoid conflicts export type { Message } from "../lib/chat/types";