diff --git a/apps/contracts/utils/write-contracts.sh b/apps/contracts/utils/utils.sh similarity index 100% rename from apps/contracts/utils/write-contracts.sh rename to apps/contracts/utils/utils.sh diff --git a/apps/web/src/app/api/agent/recommendation/route.ts b/apps/web/src/app/api/agent/recommendation/route.ts index 80f3f46..46372bd 100644 --- a/apps/web/src/app/api/agent/recommendation/route.ts +++ b/apps/web/src/app/api/agent/recommendation/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import type { AgentRecommendation } from '@/lib/agent/erc8004-agent'; +import type { AgentRecommendation } from '@/types/agent'; export const runtime = 'edge'; diff --git a/apps/web/src/app/app/page.tsx b/apps/web/src/app/app/page.tsx deleted file mode 100644 index d806a5d..0000000 --- a/apps/web/src/app/app/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import { UnifiedInterface } from "@/components/main-app/unified-interface"; - -export default function AppPage() { - return ( -
- {/* Section overlay */} -
- -
- ); -} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 4075ee1..a91996c 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -2,138 +2,19 @@ import { motion } from "framer-motion"; import { useState } from "react"; -import { - Zap, - Shield, - Bot, - ChevronDown, - ArrowRight, - RefreshCw, - TrendingUp, - Lock, - Star, - ExternalLink, -} from "lucide-react"; +import { ChevronDown, ArrowRight, Bot, Star, ExternalLink } from "lucide-react"; import { SwapInterface } from "@/components/swap/swap-interface"; -import { ConnectButton } from "@rainbow-me/rainbowkit"; +import { STATS, FEATURES, STEPS, FAQS } from "@/data/marketing"; +import { fadeInUp } from "@/lib/animations"; -// ─── Variants ───────────────────────────────────────────────────────────────── - -const fadeUp = { - hidden: { opacity: 0, y: 28 }, - show: { - opacity: 1, - y: 0, - transition: { type: "spring" as const, stiffness: 200, damping: 28 }, - }, -}; +// ─── Animation Variants ─────────────────────────────────────────────────────── const stagger = { hidden: {}, show: { transition: { staggerChildren: 0.1, delayChildren: 0.15 } }, -}; - -// ─── Data ───────────────────────────────────────────────────────────────────── - -const STATS = [ - { value: "< 5s", label: "Settlement Time" }, - { value: "0.3%", label: "Platform Fee" }, - { value: "Oracle", label: "Pricing Source" }, - { value: "ERC-8004", label: "AI Standard" }, -]; - -const FEATURES = [ - { - icon: , - title: "Mento Oracle Pricing", - desc: "Swap USDC and USDT at oracle-sourced rates — no AMM slippage, no price manipulation.", - gradient: "from-brand-blue/20 to-blue-600/5", - border: "border-brand-blue/15", - }, - { - icon: , - title: "ERC-8004 AI Agent", - desc: "An on-chain registered AI agent monitors conditions and recommends optimal slippage in real time.", - gradient: "from-purple-500/20 to-violet-600/5", - border: "border-purple-500/15", - }, - { - icon: , - title: "Fee Abstraction", - desc: "Pay gas in USDC or USDT. No CELO needed. Celo's native fee abstraction handles the rest.", - gradient: "from-yellow-500/20 to-amber-600/5", - border: "border-yellow-500/15", - }, - { - icon: , - title: "Non-Custodial", - desc: "Your keys, your tokens. Swaps execute directly from your wallet — we never hold your funds.", - gradient: "from-brand-green/20 to-emerald-600/5", - border: "border-brand-green/15", - }, - { - icon: , - title: "Circuit Breaker Protection", - desc: "Mento's circuit breaker auto-pauses trading during extreme volatility — your swap is always safe.", - gradient: "from-cyan-500/20 to-blue-600/5", - border: "border-cyan-500/15", - }, - { - icon: , - title: "Transparent Fees", - desc: "0.3% platform fee shown before every swap. No hidden charges. No surprise deductions.", - gradient: "from-rose-500/20 to-pink-600/5", - border: "border-rose-500/15", - }, -]; - -const STEPS = [ - { - num: "01", - title: "Connect Wallet", - desc: "Link any Celo-compatible wallet — Metamask, Valora, or any WalletConnect app.", - }, - { - num: "02", - title: "Enter Amount", - desc: "Type how much USDC or USDT you want to swap. A live oracle quote appears instantly.", - }, - { - num: "03", - title: "AI Reviews", - desc: "The ERC-8004 agent assesses market conditions and recommends the safest slippage setting.", - }, - { - num: "04", - title: "Confirm & Swap", - desc: "Review the fee breakdown and confirm. Your swap settles on Celo in under 5 seconds.", - }, -]; +} as const; -const FAQS = [ - { - q: "What tokens can I swap?", - a: "Jahpay supports USDC ↔ USDT on Celo Mainnet. Both are native, audited stablecoins — not bridged versions.", - }, - { - q: "How does the AI agent work?", - a: "The ERC-8004 agent is registered on-chain as an ERC-721 NFT on Celo. It monitors Mento oracle rates and recommends optimal slippage before each swap. After completion, it records feedback on-chain to build its reputation.", - }, - { - q: "What is the platform fee?", - a: "0.3% on every swap, deducted from the output amount. It is always shown transparently before you confirm.", - }, - { - q: "Do I need CELO for gas?", - a: "No. Celo's fee abstraction lets you pay gas in USDC or USDT directly.", - }, - { - q: "Is this safe?", - a: "Swaps are executed through Mento Protocol v3 — a battle-tested, audited DEX native to Celo. Jahpay never holds your funds.", - }, -]; - -// ─── FAQ Item ───────────────────────────────────────────────────────────────── +// ─── FAQ Item Component ─────────────────────────────────────────────────────── function FAQItem({ q, a }: { q: string; a: string }) { const [open, setOpen] = useState(false); @@ -165,7 +46,7 @@ function FAQItem({ q, a }: { q: string; a: string }) { export default function Home() { return ( -
+
{/* ── Background ──────────────────────────────────────────────── */}
{/* Badge */} - + ERC-8004 AI Agent · Celo Mainnet @@ -213,7 +94,7 @@ export default function Home() { {/* Headline */} - +

Swap USDC @@ -239,7 +120,7 @@ export default function Home() { {/* Sub */} Oracle-priced swaps with a 0.3% fee. An ERC-8004 AI agent @@ -249,15 +130,9 @@ export default function Home() { {/* CTAs */} - - Start Swapping - {STATS.map(({ value, label }) => ( @@ -328,20 +203,26 @@ export default function Home() { viewport={{ once: true, margin: "-80px" }} className="grid md:grid-cols-2 lg:grid-cols-3 gap-5" > - {FEATURES.map(({ icon, title, desc, gradient, border }) => ( - -
- {icon} -
-

{title}

-

{desc}

-
- ))} + {FEATURES.map( + ({ icon: IconComponent, title, desc, gradient, border }) => ( + +
+ +
+

+ {title} +

+

+ {desc} +

+
+ ), + )}

diff --git a/apps/web/src/components/main-app/core/transaction-interface.tsx b/apps/web/src/components/main-app/core/transaction-interface.tsx deleted file mode 100644 index e911056..0000000 --- a/apps/web/src/components/main-app/core/transaction-interface.tsx +++ /dev/null @@ -1,3 +0,0 @@ -// Legacy transaction-interface — replaced by SwapInterface in /components/swap/ -// Kept as a re-export shim to avoid breaking any remaining imports. -export { SwapInterface as TransactionInterface } from "@/components/swap/swap-interface"; diff --git a/apps/web/src/components/main-app/inputs/fiat-input.tsx b/apps/web/src/components/main-app/inputs/fiat-input.tsx deleted file mode 100644 index 8a6fa93..0000000 --- a/apps/web/src/components/main-app/inputs/fiat-input.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import React, { useState, useRef, useEffect } from "react"; -import { ChevronDown, Check } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "@/lib/utils"; - -interface FiatInputProps { - label: string; - currency: string; - amount: string; - onCurrencyChange: (currency: string) => void; - onAmountChange: (amount: string) => void; - readOnly?: boolean; -} - -const FIAT_CURRENCIES = [ - { code: "USD", symbol: "$", flag: "🇺🇸", name: "US Dollar" }, - { code: "EUR", symbol: "€", flag: "🇪🇺", name: "Euro" }, - { code: "GBP", symbol: "£", flag: "🇬🇧", name: "British Pound" }, - { code: "NGN", symbol: "₦", flag: "🇳🇬", name: "Nigerian Naira" }, - { code: "GHS", symbol: "₵", flag: "🇬🇭", name: "Ghanaian Cedi" }, - { code: "KES", symbol: "KSh", flag: "🇰🇪", name: "Kenyan Shilling" }, - { code: "ZAR", symbol: "R", flag: "🇿🇦", name: "South African Rand" }, -]; - -export function FiatInput({ - label, - currency, - amount, - onCurrencyChange, - onAmountChange, - readOnly = false, -}: FiatInputProps) { - const [open, setOpen] = useState(false); - const dropdownRef = useRef(null); - const selectedCurrency = - FIAT_CURRENCIES.find((f) => f.code === currency) ?? FIAT_CURRENCIES[0]; - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(e.target as Node) - ) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - return ( -
-
- - {label} - -
- -
- {/* Currency selector */} -
- - - - {open && ( - - {FIAT_CURRENCIES.map((f) => ( - - ))} - - )} - -
- - {/* Amount input */} - onAmountChange(e.target.value)} - placeholder="0.00" - readOnly={readOnly} - className={cn( - "flex-1 bg-transparent text-2xl font-semibold text-white placeholder-white/20", - "focus:outline-none min-w-0 text-right", - readOnly && "text-white/50 cursor-default", - )} - /> -
-
- ); -} diff --git a/apps/web/src/components/main-app/inputs/token-input.tsx b/apps/web/src/components/main-app/inputs/token-input.tsx deleted file mode 100644 index a2af810..0000000 --- a/apps/web/src/components/main-app/inputs/token-input.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import React, { useState, useRef, useEffect } from "react"; -import { ChevronDown, Check } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "@/lib/utils"; - -interface TokenInputProps { - label: string; - token: string; - amount: string; - onTokenChange: (token: string) => void; - onAmountChange: (amount: string) => void; - balance?: string; - readOnly?: boolean; -} - -const TOKENS = [ - { symbol: "CELO", name: "Celo", color: "#FCFF52" }, - { symbol: "cUSD", name: "Celo Dollar", color: "#00d79b" }, - { symbol: "cEUR", name: "Celo Euro", color: "#3b82f6" }, - { symbol: "USDC", name: "USD Coin", color: "#2775CA" }, - { symbol: "USDT", name: "Tether", color: "#26A17B" }, - { symbol: "ETH", name: "Ethereum", color: "#627EEA" }, - { symbol: "BTC", name: "Bitcoin", color: "#F7931A" }, -]; - -function TokenIcon({ symbol, color }: { symbol: string; color: string }) { - return ( -
- {symbol.slice(0, 2)} -
- ); -} - -export function TokenInput({ - label, - token, - amount, - onTokenChange, - onAmountChange, - balance, - readOnly = false, -}: TokenInputProps) { - const [open, setOpen] = useState(false); - const dropdownRef = useRef(null); - const selectedToken = TOKENS.find((t) => t.symbol === token) ?? TOKENS[0]; - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if ( - dropdownRef.current && - !dropdownRef.current.contains(e.target as Node) - ) { - setOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - return ( -
-
- - {label} - - {balance && ( - - )} -
- -
- {/* Amount input */} - onAmountChange(e.target.value)} - placeholder="0.00" - readOnly={readOnly} - className={cn( - "flex-1 bg-transparent text-2xl font-semibold text-white placeholder-white/20", - "focus:outline-none min-w-0", - readOnly && "text-white/50 cursor-default", - )} - /> - - {/* Token selector */} -
- - - - {open && ( - - {TOKENS.map((t) => ( - - ))} - - )} - -
-
-
- ); -} diff --git a/apps/web/src/components/main-app/panels/swap-panel.tsx b/apps/web/src/components/main-app/panels/swap-panel.tsx index a48bd2a..782db98 100644 --- a/apps/web/src/components/main-app/panels/swap-panel.tsx +++ b/apps/web/src/components/main-app/panels/swap-panel.tsx @@ -2,17 +2,8 @@ import React, { useState, useCallback, useEffect, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { - ArrowDownUp, - Loader2, - AlertCircle, - CheckCircle2, - Info, - Zap, -} from "lucide-react"; -import { useAccount, useWalletClient, usePublicClient } from "wagmi"; -import { celo } from "wagmi/chains"; -import { createWalletClient, custom, formatUnits, parseUnits } from "viem"; +import { ArrowDownUp, Loader2, AlertCircle, Info, Zap } from "lucide-react"; +import { useAccount, useWalletClient } from "wagmi"; import { getSwapQuote, buildSwapTransaction, @@ -24,174 +15,14 @@ import { getSwapRecommendation, submitSwapFeedback, } from "@/lib/agent/erc8004-agent"; -import type { SwapQuote } from "@/lib/swap/usdc-usdt-swap"; -import type { AgentRecommendation } from "@/lib/agent/erc8004-agent"; +import type { SwapQuote } from "@/types/swap"; +import type { AgentRecommendation } from "@/types/agent"; import { SWAP_CONFIG, PLATFORM_FEE_PERCENT } from "@/lib/minipay/constants"; +import { TokenBadge } from "@/components/ui/token-badge"; +import { SlippageSelector } from "@/components/ui/slippage-selector"; +import { ConfirmModal } from "@/components/ui/confirm-modal"; import { cn } from "@/lib/utils"; -// ─── Token Badge ────────────────────────────────────────────────────────────── - -function TokenBadge({ - symbol, - size = "lg", -}: { - symbol: "USDC" | "USDT"; - size?: "sm" | "lg"; -}) { - const isUSDC = symbol === "USDC"; - const sz = size === "lg" ? "w-9 h-9 text-sm" : "w-6 h-6 text-[10px]"; - return ( -
- {symbol.slice(0, 2)} -
- ); -} - -// ─── Slippage Selector ──────────────────────────────────────────────────────── - -function SlippageSelector({ - value, - onChange, - aiRecommended, -}: { - value: number; - onChange: (v: number) => void; - aiRecommended?: number; -}) { - const opts = [10, 50, 100]; - return ( -
- Slippage -
- {opts.map((bps) => ( - - ))} -
-
- ); -} - -// ─── Confirm Modal ──────────────────────────────────────────────────────────── - -function ConfirmModal({ - quote, - onConfirm, - onCancel, - isLoading, -}: { - quote: SwapQuote; - onConfirm: () => void; - onCancel: () => void; - isLoading: boolean; -}) { - return ( - - -

Confirm Swap

- -
-
-
- - - {formatTokenAmount(quote.amountIn)} {quote.fromToken} - -
- -
- - - {formatTokenAmount(quote.amountOutNet)} {quote.toToken} - -
-
- -
- {[ - { - label: "Rate", - value: `1 ${quote.fromToken} = ${quote.rate.toFixed(6)} ${quote.toToken}`, - }, - { - label: "Platform Fee (0.3%)", - value: `${formatTokenAmount(quote.platformFee)} ${quote.toToken}`, - }, - { - label: "Route", - value: - quote.route === "direct" ? "Direct" : "USDC → USDm → USDT", - }, - { label: "Slippage", value: `${quote.slippageBps / 100}%` }, - ].map(({ label, value }) => ( -
- {label} - {value} -
- ))} -
-
- -
- - -
-
-
- ); -} - // ─── Main SwapPanel ─────────────────────────────────────────────────────────── interface SwapPanelProps { @@ -537,6 +368,7 @@ function SwapPanelContent({ {showConfirm && quote && ( setShowConfirm(false)} diff --git a/apps/web/src/components/main-app/provider-selector.tsx b/apps/web/src/components/main-app/provider-selector.tsx deleted file mode 100644 index 527558d..0000000 --- a/apps/web/src/components/main-app/provider-selector.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { motion } from "framer-motion"; -import { Check, Loader2 } from "lucide-react"; -import { cn } from "@/lib/utils"; - -interface ProviderSelectorProps { - selectedProvider: string; - onProviderChange: (provider: string) => void; - fromCurrency?: string; - toCurrency?: string; - amount?: string; -} - -const PROVIDERS = [ - { - id: "yellowcard", - name: "Yellow Card", - status: "active", - }, - { - id: "cashramp", - name: "Cashramp", - status: "coming-soon", - }, - { - id: "bitmama", - name: "Bitmama", - status: "coming-soon", - }, -]; - -export function ProviderSelector({ - selectedProvider, - onProviderChange, - fromCurrency = "USD", - toCurrency = "cUSD", - amount = "100", -}: ProviderSelectorProps) { - const [rates, setRates] = useState>({}); - const [loading, setLoading] = useState(false); - - useEffect(() => { - const fetchRates = async () => { - if (!amount || isNaN(parseFloat(amount))) { - setRates({}); - return; - } - - try { - setLoading(true); - const response = await fetch("/api/providers/rates", { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (response.ok) { - const data = await response.json(); - // Format rates for each provider - const formattedRates: Record = {}; - PROVIDERS.forEach((provider) => { - const rate = data.rates?.[provider.id]; - if (rate) { - formattedRates[provider.id] = `${rate}%`; - } else { - formattedRates[provider.id] = "N/A"; - } - }); - setRates(formattedRates); - } - } catch (error) { - console.error("Failed to fetch rates:", error); - PROVIDERS.forEach((provider) => { - setRates((prev) => ({ ...prev, [provider.id]: "N/A" })); - }); - } finally { - setLoading(false); - } - }; - - fetchRates(); - }, [amount, fromCurrency, toCurrency]); - - return ( -
- - -
- {PROVIDERS.map((provider) => { - const isSelected = selectedProvider === provider.id; - const rate = rates[provider.id]; - const isComingSoon = provider.status === "coming-soon"; - - return ( - !isComingSoon && onProviderChange(provider.id)} - whileHover={!isComingSoon ? { scale: 1.02 } : {}} - whileTap={!isComingSoon ? { scale: 0.98 } : {}} - disabled={isComingSoon} - className={cn( - "relative p-3 rounded-xl border transition-all duration-200 text-left", - isComingSoon - ? "bg-white/[0.02] border-white/[0.05] cursor-not-allowed opacity-50" - : isSelected - ? "bg-brand-blue/[0.08] border-brand-blue/50" - : "bg-white/[0.03] border-white/[0.07] hover:border-white/20 hover:bg-white/[0.06]", - )} - > - {/* Selected indicator */} - {isSelected && !isComingSoon && ( - - )} - -
- - {provider.name} - - {isSelected && !isComingSoon && ( - - )} -
- -
- {isComingSoon ? ( - - Coming soon - - ) : loading ? ( - - ) : ( - - {rate || "Loading..."} - - )} -
-
- ); - })} -
-
- ); -} diff --git a/apps/web/src/components/main-app/rate-info.tsx b/apps/web/src/components/main-app/rate-info.tsx deleted file mode 100644 index dda88ea..0000000 --- a/apps/web/src/components/main-app/rate-info.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import React from "react"; -import { Zap } from "lucide-react"; - -interface RateInfoProps { - fromToken: string; - toToken: string; - rate: number; - fee: string; -} - -export function RateInfo({ fromToken, toToken, rate, fee }: RateInfoProps) { - return ( -
- {/* Live rate */} -
- - 1 {fromToken} - = - - {rate.toFixed(4)} {toToken} - -
- - {/* Fee */} -
- - Fee - {fee} -
-
- ); -} diff --git a/apps/web/src/components/main-app/transaction-summary.tsx b/apps/web/src/components/main-app/transaction-summary.tsx deleted file mode 100644 index 46f6fa0..0000000 --- a/apps/web/src/components/main-app/transaction-summary.tsx +++ /dev/null @@ -1,77 +0,0 @@ -"use client"; - -import React from "react"; -import { motion } from "framer-motion"; -import { RotateCcw, ExternalLink } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { TransactionType } from "./transaction-tabs"; - -interface TransactionSummaryProps { - type: TransactionType; - status: "loading" | "success" | "error"; - onReset: () => void; -} - -export function TransactionSummary({ - type, - status, - onReset, -}: TransactionSummaryProps) { - const getStatusMessage = () => { - switch (status) { - case "loading": - return "Processing your transaction..."; - case "success": - return "Transaction completed successfully!"; - case "error": - return "Transaction failed. Please try again."; - default: - return ""; - } - }; - - return ( -
- - ); -} diff --git a/apps/web/src/components/main-app/transaction-tabs.tsx b/apps/web/src/components/main-app/transaction-tabs.tsx deleted file mode 100644 index f414c35..0000000 --- a/apps/web/src/components/main-app/transaction-tabs.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import React from "react"; -import { motion } from "framer-motion"; -import { ArrowRightLeft, TrendingUp, TrendingDown } from "lucide-react"; -import { cn } from "@/lib/utils"; - -export type TransactionType = "swap" | "onramp" | "offramp"; - -interface TransactionTabsProps { - activeTab: TransactionType; - onTabChange: (tab: TransactionType) => void; -} - -const tabs: Array<{ - id: TransactionType; - label: string; - icon: React.ReactNode; -}> = [ - { - id: "swap", - label: "Swap", - icon: , - }, - { - id: "onramp", - label: "Buy Crypto", - icon: , - }, - { - id: "offramp", - label: "Sell Crypto", - icon: , - }, -]; - -export function TransactionTabs({ - activeTab, - onTabChange, -}: TransactionTabsProps) { - return ( -
- {tabs.map((tab) => ( - - ))} -
- ); -} diff --git a/apps/web/src/components/main-app/unified-interface.tsx b/apps/web/src/components/main-app/unified-interface.tsx deleted file mode 100644 index aa6cf35..0000000 --- a/apps/web/src/components/main-app/unified-interface.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import React, { useState, useCallback } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { - Loader2, - AlertCircle, - CheckCircle2, - Zap, - Shield, - Globe, -} from "lucide-react"; - -import { TransactionInterface } from "./core/transaction-interface"; - -export function UnifiedInterface() { - return ( -
- {/* Animated background orbs */} -
- {/* Primary blue orb */} -
- {/* Secondary green orb */} -
- {/* Mid blue accent */} -
-
- -
- {/* Tagline above card */} - -

- Powered by Celo · DeFi Made Simple -

-
- - -
-
- ); -} diff --git a/apps/web/src/components/swap/ai-agent-panel.tsx b/apps/web/src/components/swap/ai-agent-panel.tsx index eaaebfb..4ce9db2 100644 --- a/apps/web/src/components/swap/ai-agent-panel.tsx +++ b/apps/web/src/components/swap/ai-agent-panel.tsx @@ -2,12 +2,24 @@ import React, { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Bot, Zap, TrendingUp, Shield, ChevronDown, Star, Lock, Loader2 } from "lucide-react"; +import { + Bot, + Zap, + TrendingUp, + Shield, + ChevronDown, + Star, + Lock, + Loader2, +} from "lucide-react"; import { getAgentReputation } from "@/lib/agent/erc8004-agent"; -import type { AgentRecommendation } from "@/lib/agent/erc8004-agent"; -import type { AgentReputation } from "@/lib/agent/erc8004-agent"; +import type { AgentRecommendation, AgentReputation } from "@/types/agent"; import { cn } from "@/lib/utils"; -import { ThirdwebProvider, useFetchWithPayment, ConnectButton } from "thirdweb/react"; +import { + ThirdwebProvider, + useFetchWithPayment, + ConnectButton, +} from "thirdweb/react"; import { createThirdwebClient } from "thirdweb"; import { celo } from "thirdweb/chains"; @@ -21,9 +33,17 @@ interface AIAgentPanelProps { } const CONDITION_CONFIG = { - optimal: { color: "text-brand-green", dot: "bg-brand-green", label: "Optimal" }, + optimal: { + color: "text-brand-green", + dot: "bg-brand-green", + label: "Optimal", + }, normal: { color: "text-blue-400", dot: "bg-blue-400", label: "Normal" }, - volatile: { color: "text-yellow-400", dot: "bg-yellow-400", label: "Volatile" }, + volatile: { + color: "text-yellow-400", + dot: "bg-yellow-400", + label: "Volatile", + }, }; export function AIAgentPanel(props: AIAgentPanelProps) { @@ -45,7 +65,9 @@ function AIAgentPanelInner({ recommendation, className }: AIAgentPanelProps) { const [premiumError, setPremiumError] = useState(null); useEffect(() => { - getAgentReputation().then(setReputation).catch(() => {}); + getAgentReputation() + .then(setReputation) + .catch(() => {}); }, []); const condition = recommendation?.marketCondition ?? "optimal"; @@ -56,7 +78,9 @@ function AIAgentPanelInner({ recommendation, className }: AIAgentPanelProps) { try { // Calls our x402-gated API endpoint. thirdweb SDK handles the 402 challenge, // prompts the wallet for signature, and retries with the X-PAYMENT header. - const res = await fetchWithPayment("/api/agent/premium-analysis") as Response; + const res = (await fetchWithPayment( + "/api/agent/premium-analysis", + )) as Response; const json = await res.json(); if (json.data) { setPremiumData(json.data); @@ -74,7 +98,10 @@ function AIAgentPanelInner({ recommendation, className }: AIAgentPanelProps) { initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }} - className={cn("rounded-2xl border border-white/[0.06] bg-[#080d18]/80 backdrop-blur-md overflow-hidden", className)} + className={cn( + "rounded-2xl border border-white/[0.06] bg-[#080d18]/80 backdrop-blur-md overflow-hidden", + className, + )} > {/* Header row — always visible */} @@ -124,19 +160,38 @@ function AIAgentPanelInner({ recommendation, className }: AIAgentPanelProps) { >
{/* Basic AI message */} -

{recommendation.message}

+

+ {recommendation.message} +

{/* Stats row */}
{[ - { icon: , label: "Slippage", value: `${recommendation.recommendedSlippageBps / 100}%` }, - { icon: , label: "Confidence", value: `${recommendation.confidence}%` }, - { icon: , label: "Protocol", value: "Mento v3" }, + { + icon: , + label: "Slippage", + value: `${recommendation.recommendedSlippageBps / 100}%`, + }, + { + icon: , + label: "Confidence", + value: `${recommendation.confidence}%`, + }, + { + icon: , + label: "Protocol", + value: "Mento v3", + }, ].map(({ icon, label, value }) => ( -
+
{icon} {label} - {value} + + {value} +
))}
@@ -149,45 +204,78 @@ function AIAgentPanelInner({ recommendation, className }: AIAgentPanelProps) {
-

Deep Market Analysis

-

Get advanced volatility and pool health metrics paid instantly via x402 micropayments.

+

+ Deep Market Analysis +

+

+ Get advanced volatility and pool health metrics paid + instantly via x402 micropayments. +

- - {premiumError &&

{premiumError}

} - + + {premiumError && ( +

{premiumError}

+ )} + -

Powered by thirdweb x402 Agent Payments

+

+ Powered by thirdweb x402 Agent Payments +

) : (

- Premium Analysis Unlocked + Premium + Analysis Unlocked

- x402 Paid + + x402 Paid +
- Overall Sentiment - {premiumData.overallSentiment} + + Overall Sentiment + + + {premiumData.overallSentiment} +
- Pool Health - {premiumData.usdcUsdtPoolHealth.liquidityDepth} + + Pool Health + + + {premiumData.usdcUsdtPoolHealth.liquidityDepth} +
- {premiumData.macroTrends.map((trend: string, i: number) => ( -

{trend}

- ))} + {premiumData.macroTrends.map( + (trend: string, i: number) => ( +

+ {trend} +

+ ), + )}
-

{premiumData.disclaimer}

+

+ {premiumData.disclaimer} +

)}
@@ -196,7 +284,9 @@ function AIAgentPanelInner({ recommendation, className }: AIAgentPanelProps) { {reputation?.isRegistered && (
On-chain identity · Celo Mainnet - ERC-721 #{reputation.agentId?.slice(0, 6)} + + ERC-721 #{reputation.agentId?.slice(0, 6)} +
)}
diff --git a/apps/web/src/components/ui/confirm-modal.tsx b/apps/web/src/components/ui/confirm-modal.tsx new file mode 100644 index 0000000..fbcd9f3 --- /dev/null +++ b/apps/web/src/components/ui/confirm-modal.tsx @@ -0,0 +1,144 @@ +/** + * Confirm Modal Component + * Displays swap confirmation details before execution + */ + +import { motion, AnimatePresence } from "framer-motion"; +import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { SwapQuote } from "@/types/swap"; +import { formatTokenAmount } from "@/lib/swap/usdc-usdt-swap"; +import { TokenBadge } from "./token-badge"; +import { ArrowDownUp } from "lucide-react"; + +interface ConfirmModalProps { + isOpen?: boolean; + isLoading?: boolean; + title?: string; + message?: string; + details?: Record; + onConfirm: () => void; + onCancel: () => void; + confirmText?: string; + cancelText?: string; + variant?: "default" | "success" | "error"; + quote?: SwapQuote; +} + +export function ConfirmModal({ + isOpen = true, + isLoading = false, + title = "Confirm Swap", + message, + details, + onConfirm, + onCancel, + confirmText = "Confirm", + cancelText = "Cancel", + variant = "default", + quote, +}: ConfirmModalProps) { + const variantStyles = { + default: "border-brand-blue/30 bg-brand-blue/5", + success: "border-green-500/30 bg-green-500/5", + error: "border-red-500/30 bg-red-500/5", + }; + + const variantIcons = { + default: null, + success: , + error: , + }; + + // If quote is provided, build details from it + const displayDetails = quote + ? { + Rate: `1 ${quote.fromToken} = ${quote.rate.toFixed(6)} ${quote.toToken}`, + "Platform Fee (0.3%)": `${formatTokenAmount(quote.platformFee)} ${quote.toToken}`, + Route: quote.route === "direct" ? "Direct" : "USDC → USDm → USDT", + Slippage: `${quote.slippageBps / 100}%`, + } + : details; + + return ( + + {isOpen && ( + + e.stopPropagation()} + className={cn( + "rounded-2xl border p-6 max-w-sm w-full mx-4", + variantStyles[variant], + )} + > +
+ {variantIcons[variant]} +
+

{title}

+ {message && ( +

{message}

+ )} +
+
+ + {quote && ( +
+
+ + + {formatTokenAmount(quote.amountIn)} {quote.fromToken} + +
+ +
+ + + {formatTokenAmount(quote.amountOutNet)} {quote.toToken} + +
+
+ )} + + {displayDetails && ( +
+ {Object.entries(displayDetails).map(([key, value]) => ( +
+ {key} + {value} +
+ ))} +
+ )} + +
+ + +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/ui/slippage-selector.tsx b/apps/web/src/components/ui/slippage-selector.tsx new file mode 100644 index 0000000..c6c7824 --- /dev/null +++ b/apps/web/src/components/ui/slippage-selector.tsx @@ -0,0 +1,45 @@ +/** + * Slippage Selector Component + * Allows user to select slippage tolerance with AI recommendation indicator + */ + +import { cn } from "@/lib/utils"; + +interface SlippageSelectorProps { + value: number; + onChange: (v: number) => void; + aiRecommended?: number; + options?: number[]; +} + +export function SlippageSelector({ + value, + onChange, + aiRecommended, + options = [10, 50, 100], +}: SlippageSelectorProps) { + return ( +
+ Slippage +
+ {options.map((bps) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/ui/token-badge.tsx b/apps/web/src/components/ui/token-badge.tsx new file mode 100644 index 0000000..372aa39 --- /dev/null +++ b/apps/web/src/components/ui/token-badge.tsx @@ -0,0 +1,32 @@ +/** + * Token Badge Component + * Displays a token symbol with branded gradient background + */ + +import { cn } from "@/lib/utils"; + +interface TokenBadgeProps { + symbol: "USDC" | "USDT"; + size?: "sm" | "lg"; +} + +export function TokenBadge({ symbol, size = "lg" }: TokenBadgeProps) { + const isUSDC = symbol === "USDC"; + const sz = size === "lg" ? "w-9 h-9 text-sm" : "w-6 h-6 text-[10px]"; + + return ( +
+ {symbol.slice(0, 2)} +
+ ); +} diff --git a/apps/web/src/contexts/transactions-context.tsx b/apps/web/src/contexts/transactions-context.tsx index d879a24..2012703 100644 --- a/apps/web/src/contexts/transactions-context.tsx +++ b/apps/web/src/contexts/transactions-context.tsx @@ -13,7 +13,7 @@ import { TransactionType, TransactionFilters, TransactionStats, -} from "@/lib/transactions/types"; +} from "@/types/transaction"; import { TransactionService } from "@/lib/transactions/service"; interface TransactionsContextType { @@ -319,7 +319,8 @@ export function useFilteredTransactions(filters: TransactionFilters = {}) { tx.metadata.fromAddress?.toLowerCase().includes(search) || false; const matchesTo = tx.metadata.toAddress?.toLowerCase().includes(search) || false; - const matchesProvider = tx.provider ?? "Unknown".toLowerCase().includes(search); + const matchesProvider = + tx.provider ?? "Unknown".toLowerCase().includes(search); if (!(matchesId || matchesFrom || matchesTo || matchesProvider)) { return false; diff --git a/apps/web/src/data/marketing.ts b/apps/web/src/data/marketing.ts new file mode 100644 index 0000000..18e7b92 --- /dev/null +++ b/apps/web/src/data/marketing.ts @@ -0,0 +1,111 @@ +/** + * Marketing content and dummy data + * Centralized location for all static marketing copy + */ + +import { + RefreshCw, + Bot, + Zap, + Shield, + TrendingUp, + Lock, +} from "lucide-react"; + +export const STATS = [ + { value: "< 5s", label: "Settlement Time" }, + { value: "0.3%", label: "Platform Fee" }, + { value: "Oracle", label: "Pricing Source" }, + { value: "ERC-8004", label: "AI Standard" }, +] as const; + +export const FEATURES = [ + { + icon: RefreshCw, + title: "Mento Oracle Pricing", + desc: "Swap USDC and USDT at oracle-sourced rates — no AMM slippage, no price manipulation.", + gradient: "from-brand-blue/20 to-blue-600/5", + border: "border-brand-blue/15", + }, + { + icon: Bot, + title: "ERC-8004 AI Agent", + desc: "An on-chain registered AI agent monitors conditions and recommends optimal slippage in real time.", + gradient: "from-purple-500/20 to-violet-600/5", + border: "border-purple-500/15", + }, + { + icon: Zap, + title: "Fee Abstraction", + desc: "Pay gas in USDC or USDT. No CELO needed. Celo's native fee abstraction handles the rest.", + gradient: "from-yellow-500/20 to-amber-600/5", + border: "border-yellow-500/15", + }, + { + icon: Shield, + title: "Non-Custodial", + desc: "Your keys, your tokens. Swaps execute directly from your wallet — we never hold your funds.", + gradient: "from-brand-green/20 to-emerald-600/5", + border: "border-brand-green/15", + }, + { + icon: TrendingUp, + title: "Circuit Breaker Protection", + desc: "Mento's circuit breaker auto-pauses trading during extreme volatility — your swap is always safe.", + gradient: "from-cyan-500/20 to-blue-600/5", + border: "border-cyan-500/15", + }, + { + icon: Lock, + title: "Transparent Fees", + desc: "0.3% platform fee shown before every swap. No hidden charges. No surprise deductions.", + gradient: "from-rose-500/20 to-pink-600/5", + border: "border-rose-500/15", + }, +] as const; + +export const STEPS = [ + { + num: "01", + title: "Connect Wallet", + desc: "Link any Celo-compatible wallet — Metamask, Valora, or any WalletConnect app.", + }, + { + num: "02", + title: "Enter Amount", + desc: "Type how much USDC or USDT you want to swap. A live oracle quote appears instantly.", + }, + { + num: "03", + title: "AI Reviews", + desc: "The ERC-8004 agent assesses market conditions and recommends the safest slippage setting.", + }, + { + num: "04", + title: "Confirm & Swap", + desc: "Review the fee breakdown and confirm. Your swap settles on Celo in under 5 seconds.", + }, +] as const; + +export const FAQS = [ + { + q: "What tokens can I swap?", + a: "Jahpay supports USDC ↔ USDT on Celo Mainnet. Both are native, audited stablecoins — not bridged versions.", + }, + { + q: "How does the AI agent work?", + a: "The ERC-8004 agent is registered on-chain as an ERC-721 NFT on Celo. It monitors Mento oracle rates and recommends optimal slippage before each swap. After completion, it records feedback on-chain to build its reputation.", + }, + { + q: "What is the platform fee?", + a: "0.3% on every swap, deducted from the output amount. It is always shown transparently before you confirm.", + }, + { + q: "Do I need CELO for gas?", + a: "No. Celo's fee abstraction lets you pay gas in USDC or USDT directly.", + }, + { + q: "Is my swap secure?", + a: "Yes. Jahpay is non-custodial — your keys, your tokens. Swaps execute directly from your wallet. We never hold your funds.", + }, +] as const; diff --git a/apps/web/src/lib/agent/erc8004-agent.ts b/apps/web/src/lib/agent/erc8004-agent.ts index 8a7c976..f07f6d2 100644 --- a/apps/web/src/lib/agent/erc8004-agent.ts +++ b/apps/web/src/lib/agent/erc8004-agent.ts @@ -6,31 +6,7 @@ import { AGENT_CONFIG } from '../minipay/constants'; import type { SwapQuote } from '../swap/usdc-usdt-swap'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export interface AgentRecommendation { - /** Recommended slippage in BPS (10–200) */ - recommendedSlippageBps: number; - /** "optimal" | "volatile" | "normal" */ - marketCondition: 'optimal' | 'normal' | 'volatile'; - /** 0–100 confidence in the recommendation */ - confidence: number; - /** Human-readable message to show in UI */ - message: string; - /** AI badge text for the swap button */ - badge: string; - /** Whether to show the AI-optimized badge */ - showBadge: boolean; -} - -export interface AgentReputation { - agentId: string | null; - averageScore: number | null; - totalFeedback: number; - successRate: number | null; - isRegistered: boolean; -} +import type { AgentRecommendation, AgentReputation } from '@/types/agent'; // ─── Agent Registration (server-side only) ──────────────────────────────────── diff --git a/apps/web/src/lib/animations.ts b/apps/web/src/lib/animations.ts index 569f35c..1541e91 100644 --- a/apps/web/src/lib/animations.ts +++ b/apps/web/src/lib/animations.ts @@ -1,11 +1,30 @@ -// Animation presets for consistent motion throughout the app +/** + * Centralized animation presets for consistent motion throughout the app + * Single source of truth for all Framer Motion variants + */ + +import { circInOut } from 'framer-motion'; + +// ─── Fade Animations ────────────────────────────────────────────────────────── + +export const fadeIn = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { duration: 0.3, ease: circInOut } + }, + exit: { + opacity: 0, + transition: { duration: 0.2, ease: circInOut } + } +}; export const fadeInUp = { hidden: { opacity: 0, y: 30 }, visible: { opacity: 1, y: 0, - transition: { type: "spring", duration: 0.8, stiffness: 100 }, + transition: { type: "spring" as const, duration: 0.8, stiffness: 100 }, }, }; @@ -27,6 +46,8 @@ export const fadeInRight = { }, }; +// ─── Scale Animations ───────────────────────────────────────────────────────── + export const scaleIn = { hidden: { opacity: 0, scale: 0.8 }, visible: { @@ -36,6 +57,34 @@ export const scaleIn = { }, }; +// ─── Slide Animations ───────────────────────────────────────────────────────── + +export const slideIn = (direction: 'left' | 'right' | 'up' | 'down' = 'up') => { + const directions = { + left: { x: -100 }, + right: { x: 100 }, + up: { y: 100 }, + down: { y: -100 }, + }; + + return { + hidden: { ...directions[direction], opacity: 0 }, + visible: { + x: 0, + y: 0, + opacity: 1, + transition: { duration: 0.5, ease: circInOut } + }, + exit: { + ...directions[direction], + opacity: 0, + transition: { duration: 0.3, ease: circInOut } + } + }; +}; + +// ─── Container & Item Animations ────────────────────────────────────────────── + export const container = { hidden: { opacity: 0 }, show: { @@ -56,7 +105,18 @@ export const item = { }, }; -// Hover animations +export const staggerContainer = (staggerChildren: number = 0.1, delayChildren: number = 0.1) => ({ + hidden: {}, + visible: { + transition: { + staggerChildren, + delayChildren, + }, + }, +}); + +// ─── Hover Animations ───────────────────────────────────────────────────────── + export const hoverScale = { whileHover: { scale: 1.05, transition: { duration: 0.3 } }, whileTap: { scale: 0.95 }, @@ -70,7 +130,8 @@ export const hoverLift = { }, }; -// Page transitions +// ─── Page Transitions ───────────────────────────────────────────────────────── + export const pageTransition = { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.5 } }, diff --git a/apps/web/src/lib/swap/usdc-usdt-swap.ts b/apps/web/src/lib/swap/usdc-usdt-swap.ts index a5c56ff..4574d65 100644 --- a/apps/web/src/lib/swap/usdc-usdt-swap.ts +++ b/apps/web/src/lib/swap/usdc-usdt-swap.ts @@ -6,38 +6,7 @@ import { Mento, ChainId, deadlineFromMinutes } from '@mento-protocol/mento-sdk'; import { parseUnits, formatUnits } from 'viem'; import { SWAP_TOKENS, SUPPORTED_TOKENS, PLATFORM_FEE_BPS, SWAP_CONFIG } from '../minipay/constants'; - -// ─── Types ──────────────────────────────────────────────────────────────────── - -export type SwapTokenSymbol = 'USDC' | 'USDT'; - -export interface SwapQuote { - fromToken: SwapTokenSymbol; - toToken: SwapTokenSymbol; - amountIn: string; - /** Gross amount before fee */ - amountOutGross: string; - /** Net amount user receives after platform fee */ - amountOutNet: string; - /** Platform fee in output token */ - platformFee: string; - platformFeePercent: number; - /** Exchange rate (net) */ - rate: number; - /** Is this a direct swap or routed via USDm? */ - route: 'direct' | 'via-usdm'; - /** Slippage tolerance in BPS used for this quote */ - slippageBps: number; - /** Is the pair currently tradable? */ - isTradable: boolean; - timestamp: number; -} - -export interface SwapTransaction { - approval: any | null; - swap: any; - quote: SwapQuote; -} +import type { SwapTokenSymbol, SwapQuote, SwapTransaction } from '@/types/swap'; // ─── Helpers ────────────────────────────────────────────────────────────────── diff --git a/apps/web/src/types/agent.ts b/apps/web/src/types/agent.ts new file mode 100644 index 0000000..2f1725b --- /dev/null +++ b/apps/web/src/types/agent.ts @@ -0,0 +1,26 @@ +/** + * ERC-8004 Agent domain types + */ + +export interface AgentRecommendation { + /** Recommended slippage in BPS (10–200) */ + recommendedSlippageBps: number; + /** "optimal" | "volatile" | "normal" */ + marketCondition: 'optimal' | 'normal' | 'volatile'; + /** 0–100 confidence in the recommendation */ + confidence: number; + /** Human-readable message to show in UI */ + message: string; + /** AI badge text for the swap button */ + badge: string; + /** Whether to show the AI-optimized badge */ + showBadge: boolean; +} + +export interface AgentReputation { + agentId: string | null; + averageScore: number | null; + totalFeedback: number; + successRate: number | null; + isRegistered: boolean; +} diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts new file mode 100644 index 0000000..38daa94 --- /dev/null +++ b/apps/web/src/types/index.ts @@ -0,0 +1,8 @@ +/** + * Central type definitions for the Jahpay application + * Single source of truth for all domain models + */ + +export * from './swap'; +export * from './agent'; +export * from './transaction'; diff --git a/apps/web/src/types/swap.ts b/apps/web/src/types/swap.ts new file mode 100644 index 0000000..4787416 --- /dev/null +++ b/apps/web/src/types/swap.ts @@ -0,0 +1,33 @@ +/** + * Swap domain types + */ + +export type SwapTokenSymbol = 'USDC' | 'USDT'; + +export interface SwapQuote { + fromToken: SwapTokenSymbol; + toToken: SwapTokenSymbol; + amountIn: string; + /** Gross amount before fee */ + amountOutGross: string; + /** Net amount user receives after platform fee */ + amountOutNet: string; + /** Platform fee in output token */ + platformFee: string; + platformFeePercent: number; + /** Exchange rate (net) */ + rate: number; + /** Is this a direct swap or routed via USDm? */ + route: 'direct' | 'via-usdm'; + /** Slippage tolerance in BPS used for this quote */ + slippageBps: number; + /** Is the pair currently tradable? */ + isTradable: boolean; + timestamp: number; +} + +export interface SwapTransaction { + approval: any | null; + swap: any; + quote: SwapQuote; +} diff --git a/apps/web/src/types/transaction.ts b/apps/web/src/types/transaction.ts new file mode 100644 index 0000000..676b21c --- /dev/null +++ b/apps/web/src/types/transaction.ts @@ -0,0 +1,99 @@ +/** + * Transaction domain types + */ + +export interface ProviderQuote { + fromToken?: string; + toToken?: string; + fromAmount: string; + toAmount: string; + provider?: string; + rate?: number; + fee?: string; + minAmount?: string; + maxAmount?: string; + estimatedTime?: string; +} + +export enum TransactionStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', + REFUNDED = 'refunded' +} + +export enum TransactionType { + SWAP = 'swap', + SEND = 'send', + RECEIVE = 'receive', + DEPOSIT = 'deposit', + WITHDRAWAL = 'withdrawal' +} + +export interface TransactionMetadata { + providerName: string; + providerId?: string; + txHash?: string; + fromAddress?: string; + toAddress?: string; + fee?: string; + feeCurrency?: string; + timestamp: number; + blockNumber?: number; + confirmations?: number; + explorerUrl?: string; + lastRetry?: number; + lastUpdated?: number; + error?: { + code: string; + message: string; + retryable: boolean; + }; +} + +export interface Transaction extends ProviderQuote { + id: string; + status: TransactionStatus; + type: TransactionType; + metadata: TransactionMetadata; + createdAt: number; + updatedAt: number; + retryCount: number; + maxRetries: number; +} + +export interface TransactionFilters { + status?: TransactionStatus | TransactionStatus[]; + type?: TransactionType | TransactionType[]; + from?: string; + to?: string; + startDate?: number; + endDate?: number; + limit?: number; + offset?: number; + sortBy?: 'createdAt' | 'updatedAt'; + sortOrder?: 'asc' | 'desc'; + search?: string; +} + +export interface TransactionStats { + total: number; + completed: number; + pending: number; + failed: number; + totalVolume: Record; + lastUpdated: number; +} + +export interface TransactionUpdate { + id: string; + status?: TransactionStatus; + metadata?: Partial; + error?: { + code: string; + message: string; + retryable: boolean; + }; +}