Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,54 @@ const nextConfig: NextConfig = {
},
],
},
{
// Static assets served from /public (images, icons, fonts). Filenames
// are not content-hashed, so use a week-long TTL with revalidation
// rather than `immutable`.
source: '/:path*.(svg|png|jpg|jpeg|gif|webp|avif|ico|woff|woff2)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=604800, stale-while-revalidate=86400',
},
],
},
{
source: '/manifest.json',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=86400',
},
],
},
{
// Public, non-personalised marketing pages. Edge-cache them but allow
// an instant revalidate after each deploy.
source: '/(landing|about)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=0, s-maxage=3600, stale-while-revalidate=86400',
},
],
},
{
// Signed-in app *shells*. These pages render an identical HTML shell for
// every user — all wallet data loads client-side via Server Actions, and
// auth gating happens client-side from localStorage. The shells are
// therefore safe to edge-cache (short TTL + SWR). NOTE: only the static
// shell is cached; the per-user data requests are separate POSTs that
// always reach the origin. Parameterised routes (address/[address] etc.)
// are intentionally excluded.
source: '/(dashboard|analysis|security|chat|report|coin-control)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=0, s-maxage=300, stale-while-revalidate=3600',
},
],
},
];
},
// Updated experimental flags for Next.js 16
Expand Down
45 changes: 40 additions & 5 deletions src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,14 +383,37 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) {
if (process.env.NODE_ENV === 'production' && window.__NEXT_DATA__) {
const currentBuildId = window.__NEXT_DATA__.buildId;

const intervalId = setInterval(async () => {
// 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<typeof setTimeout>;
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;
}

Expand All @@ -402,16 +425,28 @@ function AppLayoutInner({ children }: { children: React.ReactNode }) {
const serverBuildId = serverData.buildId;

if (serverBuildId && currentBuildId && serverBuildId !== currentBuildId) {
// If the effect was cleaned up (unmounted) while this fetch was
// in flight, `stopped` is already true — skip the state update
// to avoid setting state on an unmounted component.
if (stopped) return;
setIsUpdateAvailable(true);
clearInterval(intervalId); // Stop checking once an update is found.
stopped = true; // Stop checking once an update is found.
return;
}
}
} catch (err) {
console.warn('Failed to check for app update:', err);
}
}, 60000); // Check every 60 seconds.

return () => clearInterval(intervalId);
scheduleNext();
};

scheduleNext();

return () => {
stopped = true;
clearTimeout(timeoutId);
};
}

}, []);
Expand Down
6 changes: 5 additions & 1 deletion src/app/(app)/market/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,11 +144,15 @@ export default function MarketPage() {
}, [loadData]);

useEffect(() => {
// Each refresh is a Server Action call to the App Hosting origin
// (Cloud Run). The underlying CoinGecko data revalidates on a 60s+
// window, so a 5-minute client poll keeps the page fresh without
// hammering the origin.
const interval = setInterval(() => {
if (document.visibilityState === 'visible') {
loadData(false);
}
}, 60000);
}, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadData]);

Expand Down
4 changes: 4 additions & 0 deletions src/app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
Lightbulb
} from 'lucide-react';

// Public marketing page — statically generated and revalidated hourly so it
// can be served from the Firebase App Hosting CDN edge.
export const revalidate = 3600;

export const metadata: Metadata = {
title: 'About BitSleuth - AI Bitcoin Wallet Analyzer | Security & Privacy Tool',
description: 'Learn about BitSleuth, the leading AI-powered Bitcoin wallet analyzer. Discover how our advanced security analysis, privacy tools, and transaction insights help Bitcoin users worldwide.',
Expand Down
4 changes: 4 additions & 0 deletions src/app/landing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
Globe
} from 'lucide-react';

// Public marketing page — statically generated and revalidated hourly so it
// can be served from the Firebase App Hosting CDN edge.
export const revalidate = 3600;

export const metadata: Metadata = {
title: 'BitSleuth - Free AI Bitcoin Wallet Analyzer & Security Tool',
description: 'The most advanced AI-powered Bitcoin wallet analyzer. Get comprehensive security insights, privacy analysis, and transaction patterns for any Bitcoin wallet. Free Bitcoin wallet scanner with AI technology.',
Expand Down
20 changes: 12 additions & 8 deletions src/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,53 @@
import { type MetadataRoute } from 'next'

// Stable timestamp so the sitemap doesn't appear "always new" to crawlers on
// every build/request. Bump when public page content changes meaningfully.
const LAST_MODIFIED = new Date('2026-06-01T00:00:00Z');

export default function sitemap(): MetadataRoute.Sitemap {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://app.bitsleuth.ai';

const staticRoutes = [
{
url: `${siteUrl}/`,
lastModified: new Date(),
lastModified: LAST_MODIFIED,
changeFrequency: 'daily' as const,
priority: 1.0,
},
{
url: `${siteUrl}/market`,
lastModified: new Date(),
lastModified: LAST_MODIFIED,
changeFrequency: 'hourly' as const,
priority: 0.9,
},
{
url: `${siteUrl}/mempool`,
lastModified: new Date(),
lastModified: LAST_MODIFIED,
changeFrequency: 'hourly' as const,
priority: 0.9,
},
{
url: `${siteUrl}/discover`,
lastModified: new Date(),
lastModified: LAST_MODIFIED,
changeFrequency: 'daily' as const,
priority: 0.8,
},
// Add more public pages that can be indexed
{
url: `${siteUrl}/landing`,
lastModified: new Date(),
lastModified: LAST_MODIFIED,
changeFrequency: 'weekly' as const,
priority: 0.9,
},
{
url: `${siteUrl}/about`,
lastModified: new Date(),
lastModified: LAST_MODIFIED,
changeFrequency: 'monthly' as const,
priority: 0.8,
},
{
url: `${siteUrl}/feedback`,
lastModified: new Date(),
lastModified: LAST_MODIFIED,
changeFrequency: 'weekly' as const,
priority: 0.6,
},
Expand Down
25 changes: 19 additions & 6 deletions src/contexts/wallet-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -908,14 +908,27 @@ export const WalletProvider = ({ children, testXpub }: { children: ReactNode; te

errorToastId.current = toastResult.id;

if (errorRetryTimeout.current) {
clearTimeout(errorRetryTimeout.current);
}
errorRetryTimeout.current = setTimeout(triggerRetry, 3000);
// Exponential backoff (3s, 6s, 12s ... capped at 60s) so a failing origin
// — wallet data is fetched via Server Actions on Cloud Run — is not hammered
// by retries. `immediate` is used when the user returns to the tab.
const scheduleRetry = (immediate = false) => {
if (errorRetryCount.current >= 3) return;
if (errorRetryTimeout.current) {
clearTimeout(errorRetryTimeout.current);
}
const delay = immediate
? 1000
: Math.min(3000 * 2 ** errorRetryCount.current, 60000);
errorRetryTimeout.current = setTimeout(triggerRetry, delay);
};

scheduleRetry();

// Retry when the user returns to the tab, but route it through the same
// debounced timer so a focus storm can't fire back-to-back origin calls.
const handleVisibility = () => {
if (!document.hidden) {
triggerRetry();
scheduleRetry(true);
}
};

Expand Down Expand Up @@ -958,7 +971,7 @@ export const WalletProvider = ({ children, testXpub }: { children: ReactNode; te
logger.warn("Could not refresh BTC price:", e);
}
scheduleNext(); // Schedule the next execution
}, 60000); // every 60 seconds
}, 5 * 60 * 1000); // every 5 minutes
};

scheduleNext();
Expand Down
Loading