diff --git a/components.json b/components.json index 7b17557f..8744a71a 100644 --- a/components.json +++ b/components.json @@ -4,7 +4,7 @@ "rsc": true, "tsx": true, "tailwind": { - "config": "tailwind.config.ts", + "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, diff --git a/next.config.ts b/next.config.ts index 4f243421..2c407757 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,9 +1,6 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - typescript: { - ignoreBuildErrors: true, - }, // Development-specific optimizations ...(process.env.NODE_ENV === 'development' && { onDemandEntries: { @@ -150,7 +147,7 @@ const nextConfig: NextConfig = { // Updated experimental flags for Next.js 16 experimental: { webVitalsAttribution: ['CLS', 'LCP'], - optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react'], + optimizePackageImports: ['@radix-ui/react-icons', 'lucide-react', 'recharts'], }, // Turbopack configuration (replaces webpack) // Turbopack has native WASM support - no custom config needed diff --git a/package-lock.json b/package-lock.json index 3d714e12..f140ebfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-virtual": "^3.14.5", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "bip32": "^5.0.1", @@ -7053,6 +7054,33 @@ "tailwindcss": "4.3.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.5.tgz", + "integrity": "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.3", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.3.tgz", + "integrity": "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", diff --git a/package.json b/package.json index 0390bdd9..374c2f25 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-virtual": "^3.14.5", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", "bip32": "^5.0.1", diff --git a/src/app/(app)/address/[address]/page.tsx b/src/app/(app)/address/[address]/page.tsx index f39505fe..aacbcb2c 100644 --- a/src/app/(app)/address/[address]/page.tsx +++ b/src/app/(app)/address/[address]/page.tsx @@ -21,8 +21,10 @@ import { } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; -import { ArrowLeft, Bitcoin, CircleAlert, ArrowUpRight, ArrowDownLeft, ArrowLeftRight } from 'lucide-react'; +import { EmptyState } from '@/components/ui/empty-state'; +import { ArrowLeft, Bitcoin, ArrowUpRight, ArrowDownLeft, ArrowLeftRight } from 'lucide-react'; import { useWallet } from '@/contexts/wallet-context'; +import { formatCurrency as formatCurrencyValue } from '@/lib/format'; import { FullPageLoader, ErrorDisplay } from '@/components/ui/loader'; import { cn } from '@/lib/utils'; import type { Transaction, AddressInfo } from '@/lib/types'; @@ -32,12 +34,7 @@ const TransactionRow = React.memo(({ tx, fiatPrice, currency }: { tx: Transactio const isReceived = tx.type === 'Received'; const fiatAmount = Math.abs(tx.btc * fiatPrice); - const formatCurrency = (value: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency, - }).format(value); - }; + const formatCurrency = (value: number) => formatCurrencyValue(value, currency); return ( @@ -80,7 +77,7 @@ const TransactionRow = React.memo(({ tx, fiatPrice, currency }: { tx: Transactio className={cn( 'text-sm', tx.status === 'Confirmed' && 'border-chart-positive/40 text-chart-positive', - tx.status === 'Pending' && 'border-yellow-500/40 text-yellow-500' + tx.status === 'Pending' && 'border-warning/40 text-warning' )} > {tx.status} @@ -152,29 +149,25 @@ export default function AddressDetailsPage() { loadData(); }, [address, loadData]); - const formatCurrency = (value: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency, - }).format(value); - }; + const formatCurrency = (value: number) => formatCurrencyValue(value, currency); if (isWalletLoading || pageIsLoading) return ; if (walletError && !pageData) return ; if (pageError) { return ( -
- -

Address Not Found

-

{pageError}

- -
+ } + /> ); } @@ -209,7 +202,7 @@ export default function AddressDetailsPage() { - + Address Balance @@ -225,7 +218,7 @@ export default function AddressDetailsPage() { - + diff --git a/src/app/(app)/analysis/page.tsx b/src/app/(app)/analysis/page.tsx index 9ca7a2ef..aafb60b8 100644 --- a/src/app/(app)/analysis/page.tsx +++ b/src/app/(app)/analysis/page.tsx @@ -23,7 +23,7 @@ import { TableRow, } from '@/components/ui/table'; import { useWallet } from '@/contexts/wallet-context'; -import { FullPageLoader, ErrorDisplay } from '@/components/ui/loader'; +import { useWalletDataGuard } from '@/components/wallet-data-guard'; import { Area, AreaChart, Bar, BarChart, CartesianGrid, Legend, Scatter, ScatterChart, XAxis, YAxis, Tooltip, ZAxis } from 'recharts'; import { useMemo } from 'react'; import { TrendingUp } from 'lucide-react'; @@ -67,6 +67,7 @@ const btcToSatsFormatter = (value: any) => { export default function AnalysisPage() { const { data, isLoading, error, activeXpub: xpub, fiatPrice, currency, isDiscovering, discoveryProgress } = useWallet(); + const walletGuard = useWalletDataGuard(); const balanceChartData = useMemo(() => { if (!data || !data.transactions || data.transactions.length === 0) { @@ -187,12 +188,8 @@ export default function AnalysisPage() { return Object.values(feesByMonth).sort((a,b) => a.key.localeCompare(b.key)); }, [data]); - const hasBlockingError = !!error && !data; - - if (!xpub) return ; - if (isLoading && !data) return ; - if (hasBlockingError) return ; - if (!data) return ; + if (walletGuard) return walletGuard; + if (!data) return null; if (balanceChartData.length <= 2) { // only has start and end points return ( @@ -231,21 +228,21 @@ export default function AnalysisPage() {
{/* Progressive Discovery Status */} {isDiscovering && discoveryProgress && ( -
+
- +
-

+

🔍 Discovering addresses... Charts updating with new data

-

+

{discoveryProgress.addressesWithActivity} addresses

- + {discoveryProgress.addressesChecked} checked
@@ -322,7 +319,7 @@ export default function AnalysisPage() { - + @@ -360,7 +357,7 @@ export default function AnalysisPage() { - + @@ -383,7 +380,7 @@ export default function AnalysisPage() { - + @@ -425,7 +422,7 @@ export default function AnalysisPage() { - + @@ -455,7 +452,7 @@ export default function AnalysisPage() { - + diff --git a/src/app/(app)/block/[id]/page.tsx b/src/app/(app)/block/[id]/page.tsx index d349fa96..56159a01 100644 --- a/src/app/(app)/block/[id]/page.tsx +++ b/src/app/(app)/block/[id]/page.tsx @@ -19,10 +19,12 @@ import { TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; +import { EmptyState } from '@/components/ui/empty-state'; import { IconContainer } from '@/components/ui/icon-container'; import { ArrowLeft, CircleAlert, Copy, Box, Download, LoaderCircle } from 'lucide-react'; import { FullPageLoader, ErrorDisplay } from '@/components/ui/loader'; import { useToast } from '@/hooks/use-toast'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import type { BlockDetails, BlockTransaction } from '@/lib/types'; import { getBlockDetails } from '@/lib/mempool'; @@ -98,27 +100,13 @@ export default function BlockDetailsPage() { }; - const handleCopy = (text: string) => { - navigator.clipboard.writeText(text); - toast({ - title: 'Copied to clipboard', - description: text, - }); - }; + const handleCopy = useCopyToClipboard(); if (pageIsLoading) return ; if (pageError) { return ( -
- -

Block Not Found

-

{pageError}

- -
+ router.back()} /> ); } @@ -158,7 +146,7 @@ export default function BlockDetailsPage() {
- + @@ -184,7 +172,7 @@ export default function BlockDetailsPage() { - + diff --git a/src/app/(app)/chat/page.tsx b/src/app/(app)/chat/page.tsx index ee76a08f..7cc66123 100644 --- a/src/app/(app)/chat/page.tsx +++ b/src/app/(app)/chat/page.tsx @@ -19,7 +19,7 @@ import { summarizeAddress } from '@/ai/flows/summarize-address'; import { cn } from '@/lib/utils'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { useWallet } from '@/contexts/wallet-context'; +import { useWalletData, useWalletActions, useWalletAi } from '@/contexts/wallet-context'; import { FullPageLoader, ErrorDisplay } from '@/components/ui/loader'; import { AiChart } from '@/components/ui/ai-chart'; import type { Message, WalletData } from '@/lib/types'; @@ -183,18 +183,9 @@ export default function ChatPage() { const { track } = useAnalytics(); const [isAiLoading, setAiLoading] = useState(false); const scrollAreaRef = useRef(null); - const { - data: walletData, - isLoading: isWalletLoading, - isLoadingAiContent, - aiContentError, - refreshAiContent, - error: walletError, - activeXpub: xpub, - messages, - setMessages, - suggestions: masterSuggestions, - } = useWallet(); + const { data: walletData, isLoading: isWalletLoading, error: walletError, activeXpub: xpub } = useWalletData(); + const { refreshAiContent } = useWalletActions(); + const { isLoadingAiContent, aiContentError, messages, setMessages, suggestions: masterSuggestions } = useWalletAi(); const [placeholder, setPlaceholder] = useState(fallbackSuggestions[0]); const [displayedSuggestions, setDisplayedSuggestions] = useState([]); @@ -468,10 +459,10 @@ export default function ChatPage() {
{/* AI Insights Loading Indicator */} {isLoadingAiContent && ( - - - Generating AI insights... - + + + Generating AI insights... + Your wallet is loaded. AI is analyzing your transactions and will provide personalized insights momentarily. diff --git a/src/app/(app)/coin-control/page.tsx b/src/app/(app)/coin-control/page.tsx index 9135664a..e083d57b 100644 --- a/src/app/(app)/coin-control/page.tsx +++ b/src/app/(app)/coin-control/page.tsx @@ -3,11 +3,12 @@ import React, { useMemo, useState } from 'react'; import { useWallet } from '@/contexts/wallet-context'; +import { useWalletDataGuard } from '@/components/wallet-data-guard'; +import { formatCurrency as formatCurrencyValue } from '@/lib/format'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { IconContainer } from '@/components/ui/icon-container'; import { Checkbox } from '@/components/ui/checkbox'; -import { FullPageLoader, ErrorDisplay } from '@/components/ui/loader'; import { AlertTriangle, Coins, Info, Link as LinkIcon, Puzzle } from 'lucide-react'; import { Treemap, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts'; import { @@ -154,6 +155,7 @@ const CustomTreemapContent = (props: any, selectedUtxos: Record export default function CoinControlPage() { const { data, isLoading, error, activeXpub, fiatPrice, currency, recommendations } = useWallet(); + const walletGuard = useWalletDataGuard(); const { state: sidebarState } = useSidebar(); // Force responsive widgets to remount when the sidebar width changes const [selectedUtxos, setSelectedUtxos] = useState>({}); @@ -232,24 +234,15 @@ export default function CoinControlPage() { }, [selectedUtxos, data?.utxos, recommendedFeeRate]); - const hasBlockingError = !!error && !data; - - if (!activeXpub) return ; - if (isLoading && !data) return ; - if (hasBlockingError) return ; - if (!data) return ; + if (walletGuard) return walletGuard; + if (!data) return null; const { utxos } = data; const numSelected = Object.values(selectedUtxos).filter(Boolean).length; const selectAllCheckedState = utxos.length > 0 && numSelected === utxos.length ? true : numSelected > 0 ? 'indeterminate' : false; const dustCount = utxos.filter(u => u.value < DUST_THRESHOLD).length; - const formatCurrency = (value: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency, - }).format(value); - }; + const formatCurrency = (value: number) => formatCurrencyValue(value, currency); return (
@@ -332,7 +325,7 @@ export default function CoinControlPage() {
- + diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index a079d16e..dd7c8dc5 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -2,7 +2,7 @@ 'use client'; import Link from 'next/link'; -import { ArrowRight, Bitcoin, ShieldCheck, TrendingUp, TrendingDown, CircleArrowUp, CircleArrowDown, ArrowUpRight, ArrowDownLeft, Activity, RefreshCw } from 'lucide-react'; +import { ArrowRight, Bitcoin, ShieldCheck, TrendingUp, TrendingDown, CircleArrowUp, CircleArrowDown, ArrowUpRight, ArrowDownLeft, Activity, RefreshCw, Lightbulb } from 'lucide-react'; import { Card, CardContent, @@ -23,6 +23,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; import { useWallet } from '@/contexts/wallet-context'; +import { formatCurrency as formatCurrencyValue } from '@/lib/format'; import { ErrorDisplay } from '@/components/ui/loader'; import { cn } from '@/lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -128,12 +129,7 @@ export default function DashboardPage() { const recentTransactions = data.transactions.slice(0, 3); - const formatCurrency = (value: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: currency, - }).format(value); - }; + const formatCurrency = (value: number) => formatCurrencyValue(value, currency); return (
@@ -172,9 +168,9 @@ export default function DashboardPage() {
-
-

- đź’ˇ What to do next: +

+

+ • Generate a receiving address from your wallet using this XPUB
• Send Bitcoin to that address
• Return here to see your balance and transaction history @@ -217,7 +213,7 @@ export default function DashboardPage() { - + Security Score @@ -235,24 +231,24 @@ export default function DashboardPage() { - + Performance (30d) = 0 ? 'emerald' : 'rose'}> {data.performance.change30d >= 0 ? : } -

= 0 ? 'text-emerald-500' : 'text-rose-500')}> +
= 0 ? 'text-success' : 'text-chart-negative')}> {data.performance.change30d >= 0 ? '+' : ''}{isFinite(data.performance.change30d) ? data.performance.change30d.toFixed(2) : '0.00'}%
- 24h: = 0 ? 'text-emerald-500' : 'text-rose-500')}>{isFinite(data.performance.change24h) ? data.performance.change24h.toFixed(2) : '0.00'}% - 7d: = 0 ? 'text-emerald-500' : 'text-rose-500')}>{isFinite(data.performance.change7d) ? data.performance.change7d.toFixed(2) : '0.00'}% + 24h: = 0 ? 'text-success' : 'text-chart-negative')}>{isFinite(data.performance.change24h) ? data.performance.change24h.toFixed(2) : '0.00'}% + 7d: = 0 ? 'text-success' : 'text-chart-negative')}>{isFinite(data.performance.change7d) ? data.performance.change7d.toFixed(2) : '0.00'}%
- + Activity (30d) @@ -260,14 +256,14 @@ export default function DashboardPage() {
- +

Inflow

{data.inflowOutflow.inflowBTC.toFixed(6)} BTC

- +

Outflow

{Math.abs(data.inflowOutflow.outflowBTC).toFixed(6)} BTC

@@ -364,7 +360,7 @@ export default function DashboardPage() { className={cn( 'text-xs sm:text-sm', tx.status === 'Confirmed' && 'border-chart-positive/40 text-chart-positive', - tx.status === 'Pending' && 'border-yellow-500/40 text-yellow-500' + tx.status === 'Pending' && 'border-warning/40 text-warning' )} > {tx.status} diff --git a/src/app/(app)/discover/page.tsx b/src/app/(app)/discover/page.tsx index cabe5cd5..1def6a56 100644 --- a/src/app/(app)/discover/page.tsx +++ b/src/app/(app)/discover/page.tsx @@ -326,7 +326,7 @@ export default function DiscoverPage() { - BitSeek Transaction Explorer + Transaction Explorer Enter a Bitcoin address or transaction ID to begin exploring its history. You can switch between a simple list view and an interactive graph view. @@ -363,7 +363,7 @@ export default function DiscoverPage() { - + @@ -386,7 +386,7 @@ export default function DiscoverPage() {
-
+

Medium Interest (Amber):

    @@ -407,7 +407,7 @@ export default function DiscoverPage() { - + diff --git a/src/app/(app)/error.tsx b/src/app/(app)/error.tsx new file mode 100644 index 00000000..0a7a2f4d --- /dev/null +++ b/src/app/(app)/error.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useEffect } from 'react'; +import { CircleAlert, RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { logger } from '@/lib/logger'; + +export default function AppSegmentError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + logger.error('[AppError] Route error boundary triggered', error); + }, [error]); + + return ( +
    +
    +
    +
    + ); +} diff --git a/src/app/(app)/feedback/page.tsx b/src/app/(app)/feedback/page.tsx index 35a33c22..e560933b 100644 --- a/src/app/(app)/feedback/page.tsx +++ b/src/app/(app)/feedback/page.tsx @@ -19,6 +19,7 @@ import { useWallet } from '@/contexts/wallet-context'; import { useAnalytics } from '@/hooks/use-analytics'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useToast } from '@/hooks/use-toast'; +import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { Separator } from '@/components/ui/separator'; import { logger } from '@/lib/logger'; @@ -47,13 +48,8 @@ export default function FeedbackPage() { const { data: walletData } = useWallet(); const { track } = useAnalytics(); - const handleCopy = (textToCopy: string, type: string) => { - navigator.clipboard.writeText(textToCopy); - toast({ - title: 'Copied to clipboard', - description: `${type}: ${textToCopy}`, - }); - }; + const copyToClipboard = useCopyToClipboard(); + const handleCopy = (textToCopy: string, type: string) => copyToClipboard(textToCopy, `${type}: ${textToCopy}`); async function onSubmit(data: z.infer) { setIsLoading(true); @@ -182,7 +178,7 @@ export default function FeedbackPage() { - + diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index f28e268c..6e65cd44 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -4,10 +4,7 @@ import * as React from 'react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { ArrowLeftRight, LayoutDashboard, MessageCircle, Shield, Bitcoin, ChartLine, Star, Terminal, Pencil, ChevronsUpDown, CirclePlus, Check, X, LoaderCircle, Compass, Layers, ChartCandlestick, FileSpreadsheet, Coins } from 'lucide-react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; +import { ArrowLeftRight, LayoutDashboard, MessageCircle, Shield, ChartLine, Star, Compass, Layers, ChartCandlestick, FileSpreadsheet, Coins } from 'lucide-react'; import { SidebarProvider, @@ -23,25 +20,18 @@ import { useSidebar, SidebarSeparator, } from '@/components/ui/sidebar'; -import { Logo, OstrichIcon } from '@/components/icons'; +import { Logo } from '@/components/icons'; import { Button } from '@/components/ui/button'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { useWallet } from '@/contexts/wallet-context'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { useToast } from "@/hooks/use-toast"; -import { Textarea } from '@/components/ui/textarea'; -import { Popover, PopoverContent, PopoverPortal, PopoverTrigger } from '@/components/ui/popover'; +import { useWalletData, useWalletActions } from '@/contexts/wallet-context'; +import { useToast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; -import { Separator } from '@/components/ui/separator'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import type { Currency } from '@/lib/types'; import { ToastAction } from '@/components/ui/toast'; import { ThemeToggle } from '@/components/theme-toggle'; import { logger } from '@/lib/logger'; +import { AnalyticsWarning } from '@/components/layout/analytics-warning'; +import { AccountSwitcher } from '@/components/layout/account-switcher'; +import { CurrencySwitcher } from '@/components/layout/currency-switcher'; +import { NostrAccount } from '@/components/layout/nostr-account'; const navItems = [ { href: '/dashboard', icon: LayoutDashboard, label: 'Dashboard', description: "Get a high-level overview of your wallet's balance, security, and recent activity." }, @@ -57,266 +47,18 @@ const navItems = [ { href: '/feedback', icon: Star, label: 'Feedback', description: "Share your thoughts and suggestions to help us improve BitSleuth." }, ]; -function AnalyticsWarning() { - const [isConfigured, setIsConfigured] = React.useState(true); - - // We check on mount to avoid hydration errors - React.useEffect(() => { - setIsConfigured(!!process.env.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID); - }, []); - - if (isConfigured) { - return null; - } - - return ( - - - Analytics Not Configured - - Your Google Analytics measurement ID is missing. To enable analytics, add NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID to the .env file, then restart the development server. - - - ) -} - -const AddAccountFormSchema = z.object({ - xpub: z.string().min(1, { message: "XPUB key is required." }), -}); - -function AddAccountDialog({ open, onOpenChange }: { open: boolean, onOpenChange: (open: boolean) => void }) { - const { addXpub } = useWallet(); - const { toast } = useToast(); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - const form = useForm>({ - resolver: zodResolver(AddAccountFormSchema), - defaultValues: { xpub: "" }, - }); - - async function onSubmit(values: z.infer) { - setIsSubmitting(true); - form.clearErrors('xpub'); - const result = await addXpub(values.xpub); - if (result.error) { - form.setError("xpub", { type: "manual", message: result.error }); - } else { - toast({ title: "XPUB Key Added", description: "Successfully connected the new xpub." }); - onOpenChange(false); - form.reset(); - } - setIsSubmitting(false); - } - - return ( - { onOpenChange(isOpen); if (!isOpen) form.reset(); }}> - - - - Add New XPUB Key - - Enter a Bitcoin xpub key to add it to your list of wallets. - - -
    - - ( - - XPUB Key - - - - - - )} - /> - - - - - -
    -
    - ) -} - -function AccountSwitcher() { - const { activeXpub, xpubs, setActiveXpub, removeXpub, refetch } = useWallet(); - const { isMobile, setOpenMobile } = useSidebar(); - const [isAddAccountDialogOpen, setAddAccountDialogOpen] = React.useState(false); - const [isPopoverOpen, setPopoverOpen] = React.useState(false); - - if (!activeXpub) return null; - - const handleSelectWallet = (xpub: string) => { - if (xpub === activeXpub) { - refetch(); - } else { - setActiveXpub(xpub); - } - setPopoverOpen(false); - if (isMobile) { - setOpenMobile(false); - } - } - - const handleAddWalletClick = () => { - setPopoverOpen(false); - setAddAccountDialogOpen(true); - } - - const handleTriggerClick = (e: React.MouseEvent) => { - if (isMobile) { - e.stopPropagation(); - } - setPopoverOpen((v) => !v); - } - - return ( - <> - - -
    -
    - - - - - -
    -
    -
    -
    - Active XPUB Key - {`${activeXpub.substring(0, 12)}...`} -
    - -
    -
    -
    -
    - - { - if (isMobile) { - e.preventDefault(); - } - }} - > -
    - {xpubs.map((xpub) => ( -
    handleSelectWallet(xpub)} - className={cn( - "group flex w-full cursor-pointer items-center gap-2 rounded-md p-2 text-left text-sm transition-colors hover:bg-accent/50", - activeXpub === xpub && "bg-primary text-primary-foreground" - )} - role="button" - > - - {xpub} - -
    - ))} -
    - -
    - -
    -
    -
    -
    - - - ) -} - -function CurrencySwitcher() { - const { currency, setCurrency, supportedCurrencies } = useWallet(); - - if (!currency) return null; - - return ( - - ); -} - - function AppLayoutInner({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const router = useRouter(); - const { activeXpub, isLoading, disconnect, nostrNpub, nostrProfile, isNostrReady, connectNostr, updateNostrProfile, showSaveXpubsPrompt, setShowSaveXpubsPrompt, saveXpubsToNostr } = useWallet(); + const { activeXpub, isLoading } = useWalletData(); + const { disconnect } = useWalletActions(); const { setOpenMobile, isMobile } = useSidebar(); const { toast } = useToast(); - const [isNostrDialogOpen, setNostrDialogOpen] = React.useState(false); - const [isEditProfileOpen, setEditProfileOpen] = React.useState(false); - const [isUpdateAvailable, setIsUpdateAvailable] = React.useState(false); - const [isUpdateDismissed, setIsUpdateDismissed] = React.useState(false); // Define public paths that do not require a wallet to be connected. const publicPaths = ['/market', '/mempool', '/discover', '/block/', '/transactions/', '/address/']; const isPublicPage = publicPaths.some(p => pathname.startsWith(p)); - + // The root '/transactions' page (the list) is always protected. const isTransactionsListPage = pathname === '/transactions'; @@ -330,127 +72,6 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) { } }, [activeXpub, isLoading, router, isProtectedRoute, pathname]); - const NostrFormSchema = z.object({ - nsec: z.string().startsWith('nsec1', { message: 'Nostr private key must start with "nsec1"' }), - }); - - const nostrForm = useForm>({ - resolver: zodResolver(NostrFormSchema), - defaultValues: { nsec: "" }, - }); - - const EditProfileSchema = z.object({ - display_name: z.string().max(50).optional(), - name: z.string().max(50).optional(), - about: z.string().max(1024).optional(), - website: z.string().url({ message: 'Please enter a valid website URL.' }).optional().or(z.literal('')), - picture: z.string().url({ message: 'Please enter a valid avatar URL.' }).optional().or(z.literal('')), - banner: z.string().url({ message: 'Please enter a valid banner URL.' }).optional().or(z.literal('')), - nip05: z.string().optional(), - lud16: z.string().optional(), - }); - - const editProfileForm = useForm>({ - resolver: zodResolver(EditProfileSchema), - defaultValues: { - display_name: "", - name: "", - about: "", - website: "", - picture: "", - banner: "", - nip05: "", - lud16: "", - } - }); - - // Effect for checking for new version - React.useEffect(() => { - // This check should only run on the client. - if (typeof window === 'undefined') { - return; - } - - // For development: pop up the alert after 1 minute to make it testable. - if (process.env.NODE_ENV === 'development') { - const devTimeout = setTimeout(() => { - setIsUpdateAvailable(true); - }, 60000); // 1 minute - return () => clearTimeout(devTimeout); - } - - // For production: check against the build ID. - if (process.env.NODE_ENV === 'production' && window.__NEXT_DATA__) { - const currentBuildId = window.__NEXT_DATA__.buildId; - - // This fetch hits the App Hosting origin (Cloud Run) with `no-store`, - // so it is both a CDN miss and an origin request. Keep it infrequent, - // skip it while the tab is hidden, and add jitter so clients don't all - // poll in lockstep. - const BASE_INTERVAL = 15 * 60 * 1000; // 15 minutes - const JITTER = 60 * 1000; // up to 60s of spread - let timeoutId: ReturnType; - let stopped = false; - - const scheduleNext = () => { - const delay = BASE_INTERVAL + Math.floor(Math.random() * JITTER); - timeoutId = setTimeout(check, delay); - }; - - const check = async () => { - if (stopped) return; - - // Don't poll the origin while the tab is in the background. - if (document.visibilityState !== 'visible') { - scheduleNext(); - return; - } - - try { - const res = await fetch(window.location.href, { - cache: 'no-store', - }); - - if (!res.ok) { - console.warn(`Update check failed with status: ${res.status}`); - scheduleNext(); - return; - } - - const html = await res.text(); - const match = html.match(/