diff --git a/app/.well-known/farcaster.json/route.ts b/app/.well-known/farcaster.json/route.ts
new file mode 100644
index 00000000..6bcef856
--- /dev/null
+++ b/app/.well-known/farcaster.json/route.ts
@@ -0,0 +1,53 @@
+import { NextRequest, NextResponse } from "next/server";
+import config from "../../lib/config";
+
+export async function GET() {
+ const assetBaseUrl = "https://noblocks.xyz";
+
+ const manifest: any = {
+ accountAssociation: {
+ header: config.baseAppHeader,
+ payload: config.baseAppPayload,
+ signature: config.baseAppSignature,
+ },
+ baseBuilder: {
+ ownerAddress: config.baseBuilderOwnerAddress,
+ },
+ miniapp: {
+ version: "1",
+ name: "Noblocks",
+ homeUrl: assetBaseUrl,
+ iconUrl: `${assetBaseUrl}/icons/android-chrome-192x192.png`,
+ imageUrl: `${assetBaseUrl}/images/og-image.jpg`,
+ splashImageUrl: `${assetBaseUrl}/images/noblocks-bg-image.png`,
+ splashBackgroundColor: "#8B85F4",
+ subtitle: "Decentralized Payments",
+ description:
+ "Send crypto payments to any bank or mobile wallet via distributed liquidity network.",
+ screenshotUrls: [
+ `${assetBaseUrl}/screenshots/mobile-narrow.png`,
+ `${assetBaseUrl}/screenshots/desktop-wide.png`,
+ ],
+ primaryCategory: "finance",
+ tags: ["payments", "crypto", "remittance", "defi"],
+ heroImageUrl: `${assetBaseUrl}/images/noblocks-bg-image.png`,
+ tagline: "Crypto-to-fiat payments",
+ ogTitle: "Noblocks",
+ ogDescription: "Decentralized payments to any bank or mobile wallet via distributed liquidity network.",
+ ogImageUrl: `${assetBaseUrl}/images/og-image.jpg`,
+ noindex: false,
+ },
+ };
+
+ return NextResponse.json(manifest, {
+ headers: {
+ "Content-Type": "application/json",
+ // Disable caching completely during testing
+ "Cache-Control": "no-cache, no-store, must-revalidate, max-age=0",
+ "Pragma": "no-cache",
+ "Expires": "0",
+ // Explicitly allow framing
+ "X-Frame-Options": "",
+ },
+ });
+}
\ No newline at end of file
diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx
new file mode 100644
index 00000000..f1a5757a
--- /dev/null
+++ b/app/api/og/route.tsx
@@ -0,0 +1,81 @@
+import { ImageResponse } from "next/og";
+
+export async function GET() {
+ return new ImageResponse(
+ (
+
+ {/* Logo/Brand */}
+
+ Noblocks
+
+
+ {/* Tagline */}
+
+ Change stablecoins to cash in seconds
+
+
+ {/* Subtitle */}
+
+ Decentralized payments to any bank or mobile wallet
+
+
+ {/* Visual element */}
+
+ noblocks.xyz
+
+
+ ),
+ {
+ width: 1200,
+ height: 630,
+ },
+ );
+}
diff --git a/app/blog/[id]/page.tsx b/app/blog/[id]/page.tsx
index a9f68a58..e947abac 100644
--- a/app/blog/[id]/page.tsx
+++ b/app/blog/[id]/page.tsx
@@ -1,6 +1,6 @@
import DetailClient from "@/app/components/blog/post/detail-client";
import { getPost, getRecentPosts } from "@/app/lib/sanity-data";
-import { notFound } from "next/navigation";
+import { notFound, redirect } from "next/navigation";
import { Metadata } from "next";
import type { PortableTextBlock } from "@portabletext/types";
@@ -109,9 +109,17 @@ export async function generateMetadata({
export default async function BlogPostDetailPage({
params,
+ searchParams,
}: {
params: Promise<{ id: string }>;
+ searchParams: Promise<{ mini?: string }>;
}) {
+ // Redirect to home page if in mini mode
+ const resolvedSearchParams = await searchParams;
+ if (resolvedSearchParams.mini === "true") {
+ redirect("/?mini=true");
+ }
+
const { id } = await params;
const post = await getPost(id);
if (!post) notFound();
diff --git a/app/blog/page.tsx b/app/blog/page.tsx
index 81d29f87..5459d0ef 100644
--- a/app/blog/page.tsx
+++ b/app/blog/page.tsx
@@ -2,6 +2,7 @@ import React, { Suspense } from "react";
import { getPosts, getCategories, getCachedPosts } from "@/app/lib/sanity-data";
import HomeClient from "@/app/components/blog/home-client";
import { Metadata } from "next";
+import { redirect } from "next/navigation";
// Force dynamic rendering to ensure fresh data
export const dynamic = "force-dynamic";
@@ -43,7 +44,17 @@ export async function generateMetadata(): Promise {
};
}
-export default async function Home() {
+export default async function Home({
+ searchParams,
+}: {
+ searchParams: Promise<{ mini?: string }>;
+}) {
+ // Redirect to home page if in mini mode
+ const resolvedSearchParams = await searchParams;
+ if (resolvedSearchParams.mini === "true") {
+ redirect("/?mini=true");
+ }
+
// Fetch data from Sanity (cached to avoid duplicate fetch in metadata)
const sanityPosts = await getCachedPosts();
const sanityCategories = await getCategories();
diff --git a/app/components/AppLayout.tsx b/app/components/AppLayout.tsx
index 99b79889..c573cb2a 100644
--- a/app/components/AppLayout.tsx
+++ b/app/components/AppLayout.tsx
@@ -1,8 +1,8 @@
-import React from "react";
+"use client";
+import React, { useEffect, useState } from "react";
import Script from "next/script";
import config from "../lib/config";
-import Providers from "../providers";
import MainContent from "../mainContent";
import {
Footer,
@@ -11,22 +11,36 @@ import {
PWAInstall,
NoticeBanner,
} from "./index";
+import { useBaseApp } from "../context";
export default function AppLayout({ children }: { children: React.ReactNode }) {
+ const { isBaseApp, isFarcaster } = useBaseApp();
+ const [isMounted, setIsMounted] = useState(false);
+
+ // Only check mini app status after mount to prevent hydration mismatch
+ useEffect(() => {
+ setIsMounted(true);
+ }, []);
+
+ // Always render full UI on server, then conditionally hide on client after mount
+ const isMiniApp = isMounted && (isBaseApp || isFarcaster);
+
return (
-
+ <>
-
-
- {config.noticeBannerText && (
-
- )}
-
-
}>
+ {!isMiniApp && (
+
+
+ {config.noticeBannerText && (
+
+ )}
+
+ )}
+
: undefined}>
{children}
-
+ {!isMiniApp &&
}
{/* Brevo Chat Widget */}
@@ -44,6 +58,6 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
/>{" "}
>
)}
-
+ >
);
}
diff --git a/app/components/HomePage.tsx b/app/components/HomePage.tsx
index 041616a2..3c381bd8 100644
--- a/app/components/HomePage.tsx
+++ b/app/components/HomePage.tsx
@@ -143,7 +143,7 @@ function HomePageComponent({
- {/* All additional content - always visible, scroll-triggered animations */}
+ {/* All additional content - scroll-triggered animations */}
account.type === "smart_wallet")
- ?.address;
+ ?.address;
return (
<>
@@ -149,30 +149,30 @@ export function MainPageContent() {
// State props for child components
const stateProps: StateProps = {
- formValues,
- setFormValues,
-
- rate,
- setRate,
- isFetchingRate,
- setIsFetchingRate,
- rateError,
- setRateError,
-
- institutions,
- setInstitutions,
- isFetchingInstitutions,
- setIsFetchingInstitutions,
-
- selectedRecipient,
- setSelectedRecipient,
-
- orderId,
- setOrderId,
- setCreatedAt,
- setTransactionStatus,
- }
-
+ formValues,
+ setFormValues,
+
+ rate,
+ setRate,
+ isFetchingRate,
+ setIsFetchingRate,
+ rateError,
+ setRateError,
+
+ institutions,
+ setInstitutions,
+ isFetchingInstitutions,
+ setIsFetchingInstitutions,
+
+ selectedRecipient,
+ setSelectedRecipient,
+
+ orderId,
+ setOrderId,
+ setCreatedAt,
+ setTransactionStatus,
+ }
+
useEffect(function setPageLoadingState() {
setOrderId("");
setIsPageLoading(false);
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx
index 17cff2aa..12d09495 100644
--- a/app/components/Navbar.tsx
+++ b/app/components/Navbar.tsx
@@ -193,9 +193,8 @@ export const Navbar = () => {
Swap
diff --git a/app/context/BaseAppContext.tsx b/app/context/BaseAppContext.tsx
new file mode 100644
index 00000000..951be835
--- /dev/null
+++ b/app/context/BaseAppContext.tsx
@@ -0,0 +1,182 @@
+"use client";
+import {
+ ReactNode,
+ createContext,
+ useContext,
+ useState,
+ useEffect,
+ Suspense,
+} from "react";
+import { useSearchParams } from "next/navigation";
+
+interface BaseAppContextType {
+ isBaseApp: boolean;
+ isFarcaster: boolean;
+ baseAppWallet: string | null;
+ baseAppReady: boolean;
+ sdk: any | null;
+}
+
+const BaseAppContext = createContext
({
+ isBaseApp: false,
+ isFarcaster: false,
+ baseAppWallet: null,
+ baseAppReady: false,
+ sdk: null,
+});
+
+function BaseAppProviderContent({ children }: { children: ReactNode }) {
+ const searchParams = useSearchParams();
+
+ // Always start with false to ensure server/client hydration match
+ // We'll update these in useEffect after mount
+ const [isBaseAppEnv, setIsBaseAppEnv] = useState(false);
+ const [isFarcasterEnv, setIsFarcasterEnv] = useState(false);
+ const [baseAppWallet, setBaseAppWallet] = useState(null);
+ const [baseAppReady, setBaseAppReady] = useState(false);
+ const [sdk, setSdk] = useState(null);
+ const [isMounted, setIsMounted] = useState(false);
+
+ useEffect(() => {
+ setIsMounted(true);
+
+ const initBaseApp = async () => {
+ // Check for query params for local testing (including Farcaster pattern)
+ const baseAppParam = searchParams?.get("baseApp");
+ const miniParam = searchParams?.get("mini");
+ const miniAppParam = searchParams?.get("miniApp");
+ const shouldUseForTesting = baseAppParam === "true" || miniParam === "true" || miniAppParam === "true";
+
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ // Check URL query param and pathname (Farcaster pattern: /mini pathname)
+ const urlParams = new URLSearchParams(window.location.search);
+ const urlPathname = window.location.pathname;
+ const hasBaseAppParam = urlParams.get("baseApp") === "true" || urlParams.get("mini") === "true" || urlParams.get("miniApp") === "true";
+ const hasMiniPathname = urlPathname.startsWith("/mini"); // Farcaster docs pattern
+
+ // Check for Base App MiniKit API
+ const hasMinikit = !!(window as any).minikit;
+
+ // Check user agent for Base App/Farcaster
+ const userAgent = window.navigator?.userAgent?.toLowerCase() || "";
+ const hasBaseAppUA = userAgent.includes("baseapp");
+ const hasFarcasterUA = userAgent.includes("farcaster") || userAgent.includes("warpcast");
+
+ if (hasBaseAppParam || hasMiniPathname || hasMinikit || hasBaseAppUA) {
+ setIsBaseAppEnv(true);
+ }
+ if (hasFarcasterUA || hasMiniPathname) {
+ setIsFarcasterEnv(true);
+ }
+
+ try {
+ // Import and initialize Farcaster MiniApp SDK (works for both Farcaster and Base App)
+ const { sdk: farcasterSdk } = await import("@farcaster/miniapp-sdk");
+ setSdk(farcasterSdk);
+
+ try {
+ await farcasterSdk.actions.ready();
+ setBaseAppReady(true);
+ } catch (readyError) {
+ // ready() may fail if not in actual mini app environment
+ console.warn("[BaseApp/Farcaster] SDK ready() call (may fail outside mini app):", readyError);
+ // For testing, mark as ready anyway
+ if (shouldUseForTesting) {
+ setBaseAppReady(true);
+ }
+ }
+
+ // Check if we're actually in a Mini App using SDK method
+ let isInMiniApp = false;
+ try {
+ isInMiniApp = await farcasterSdk.isInMiniApp();
+ } catch (error) {
+ console.warn("[BaseApp/Farcaster] isInMiniApp() check failed:", error);
+ }
+
+ // Determine if it's Farcaster or Base App
+ let isFarcaster = false;
+ let isBaseApp = false;
+
+ if (isInMiniApp || shouldUseForTesting) {
+ try {
+ const context = await farcasterSdk.context;
+ const platform = (context as any)?.client?.platformType;
+ const userAgent = window.navigator?.userAgent?.toLowerCase() || "";
+
+ // Check if it's Farcaster (Warpcast, etc.)
+ if (userAgent.includes("farcaster") || userAgent.includes("warpcast")) {
+ isFarcaster = true;
+ } else if (userAgent.includes("baseapp") || (window as any).minikit) {
+ // Check if it's Base App
+ isBaseApp = true;
+ } else if (platform === "mobile" || platform === "web") {
+ // Default to Farcaster if in mini app but platform type is generic
+ isFarcaster = true;
+ }
+
+ setIsFarcasterEnv(isFarcaster);
+ setIsBaseAppEnv(isBaseApp || shouldUseForTesting);
+
+ // Extract wallet address from user context
+ const walletAddress =
+ (context as any)?.user?.custodyAddress ||
+ (context as any)?.user?.verifications?.[0] ||
+ null;
+
+ if (walletAddress) {
+ setBaseAppWallet(walletAddress);
+ console.log("[BaseApp/Farcaster] Wallet address extracted:", walletAddress);
+ }
+ } catch (error) {
+ console.warn("[BaseApp/Farcaster] Failed to get context:", error);
+ // Default to Base App for testing
+ if (shouldUseForTesting) {
+ setIsBaseAppEnv(true);
+ }
+ }
+ } else if (shouldUseForTesting) {
+ // For testing mode, default to Base App
+ setIsBaseAppEnv(true);
+ setBaseAppReady(true);
+ }
+ } catch (error) {
+ console.error("[BaseApp/Farcaster] Failed to initialize SDK:", error);
+ // For local testing, mark as ready even without SDK
+ if (shouldUseForTesting) {
+ setIsBaseAppEnv(true);
+ setBaseAppReady(true);
+ }
+ }
+ };
+
+ initBaseApp();
+ }, [searchParams]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const BaseAppProvider = ({ children }: { children: ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const useBaseApp = () => useContext(BaseAppContext);
diff --git a/app/context/InjectedWalletContext.tsx b/app/context/InjectedWalletContext.tsx
index 6a13dacb..a7affb57 100644
--- a/app/context/InjectedWalletContext.tsx
+++ b/app/context/InjectedWalletContext.tsx
@@ -11,6 +11,7 @@ import { createWalletClient, custom } from "viem";
import { toast } from "sonner";
import { useSearchParams } from "next/navigation";
import { shouldUseInjectedWallet } from "../utils";
+import { sdk } from "@farcaster/miniapp-sdk";
interface InjectedWalletContextType {
isInjectedWallet: boolean;
@@ -42,17 +43,38 @@ function InjectedWalletProviderContent({ children }: { children: ReactNode }) {
if (shouldUse && window.ethereum) {
try {
+ let ethereumProvider = window.ethereum;
+
+ // Try to use Farcaster SDK's Ethereum provider if we're in a mini app
+ try {
+ const isInMiniApp = await sdk.isInMiniApp();
+ if (isInMiniApp) {
+ const farcasterProvider = await sdk.wallet.getEthereumProvider();
+ if (farcasterProvider && typeof farcasterProvider.request === "function") {
+ ethereumProvider = farcasterProvider;
+ }
+ }
+ } catch {
+ // Not in mini app or Farcaster SDK provider not available, use window.ethereum
+ }
+
const client = createWalletClient({
- transport: custom(window.ethereum as any),
+ transport: custom(ethereumProvider as any),
});
- await (window.ethereum as any).request({ method: "eth_requestAccounts" });
+ // Add a small delay to ensure provider is ready
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ await (ethereumProvider as any).request({
+ method: "eth_requestAccounts",
+ });
const [address] = await client.getAddresses();
if (address) {
- setInjectedProvider(window.ethereum);
+ setInjectedProvider(ethereumProvider);
setInjectedAddress(address);
setInjectedReady(true);
+ console.log("Successfully connected to wallet:", address);
} else {
console.warn("No address returned from injected wallet.");
toast.error(
@@ -72,17 +94,31 @@ function InjectedWalletProviderContent({ children }: { children: ReactNode }) {
setInjectedProvider(null);
setInjectedAddress(null);
setInjectedReady(false);
+ } else if ((error as any)?.message?.includes("User rejected")) {
+ toast.error("Wallet connection was cancelled by user.", {
+ description: "You can try connecting again later.",
+ });
+ setIsInjectedWallet(false);
} else {
toast.error(
"Failed to connect to wallet. Please refresh and try again.",
+ {
+ description: "If the problem persists, try restarting the app.",
+ },
);
setIsInjectedWallet(false);
}
}
}
};
+ // Add a small delay to ensure the page is fully loaded
- initInjectedWallet();
+ let cancelled = false;
+ const timeoutId = setTimeout(() => !cancelled && initInjectedWallet(), 200);
+ return () => {
+ cancelled = true;
+ clearTimeout(timeoutId);
+ };
}, [searchParams]);
return (
diff --git a/app/context/index.ts b/app/context/index.ts
index e82de079..98802e53 100644
--- a/app/context/index.ts
+++ b/app/context/index.ts
@@ -12,3 +12,4 @@ export {
BlockFestModalProvider,
useBlockFestModal,
} from "./BlockFestModalContext";
+export { BaseAppProvider, useBaseApp } from "./BaseAppContext";
diff --git a/app/early-ready.tsx b/app/early-ready.tsx
new file mode 100644
index 00000000..5b45445d
--- /dev/null
+++ b/app/early-ready.tsx
@@ -0,0 +1,60 @@
+"use client";
+import { useEffect } from "react";
+import { sdk } from "@farcaster/miniapp-sdk";
+
+/**
+ * Early bootstrap component for Farcaster/Base App mini apps
+ * Follows Farcaster docs pattern with /mini pathname check for mobile compatibility
+ * Always calls ready() unconditionally (required for Base App preview tools)
+ */
+export function EarlyReady() {
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+
+ // Check if already initialized
+ if ((window as any).__farcasterMiniAppReady) return;
+
+ // Check for mini app conditions (Farcaster docs pattern + Base App)
+ const url = new URL(window.location.href);
+ const isMini =
+ url.pathname.startsWith("/mini") ||
+ url.searchParams.get("miniApp") === "true" ||
+ url.searchParams.get("mini") === "true" ||
+ url.searchParams.get("baseApp") === "true" ||
+ !!(window as any).minikit || // Base App MiniKit
+ window.navigator?.userAgent?.toLowerCase().includes("baseapp") ||
+ window.navigator?.userAgent?.toLowerCase().includes("farcaster") ||
+ window.navigator?.userAgent?.toLowerCase().includes("warpcast") ||
+ window.top !== window.self; // In iframe
+
+
+ if (isMini || true) { // Always call for Base App compatibility
+ try {
+ // Notify Farcaster SDK
+ sdk.actions.ready();
+
+ // Defensive fallback for iframe hosts
+ try {
+ if (window.parent && window.top !== window.self) {
+ const msgs = [
+ { type: "farcaster:ready" },
+ { type: "miniapp:ready" },
+ { type: "frame:ready" },
+ { type: "farcaster:miniapp:ready" },
+ ];
+ msgs.forEach((m) => {
+ window.parent!.postMessage(m, "*");
+ });
+ }
+ } catch { }
+
+ (window as any).__farcasterMiniAppReady = true;
+ } catch (error) {
+ // May fail if not in actual mini app environment (expected)
+ console.warn("[EarlyReady] SDK ready() call failed (expected outside mini app):", error);
+ }
+ }
+ }, []);
+
+ return null;
+}
diff --git a/app/head.tsx b/app/head.tsx
new file mode 100644
index 00000000..d440d198
--- /dev/null
+++ b/app/head.tsx
@@ -0,0 +1,29 @@
+import config from "./lib/config";
+
+export default function Head() {
+ const { appUrl } = config;
+
+ const baseUrl = appUrl.replace(/\/$/, "");
+ const miniAppJson = JSON.stringify({
+ url: baseUrl,
+ window: { height: 600, width: 400 },
+ });
+
+ return (
+ <>
+ Noblocks - Decentralized Payments Interface
+
+
+ {/* Farcaster Mini App embed */}
+
+
+
+
+ {/* Keep your other important tags */}
+
+ >
+ );
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 7a9105e9..f57c3d50 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -3,7 +3,9 @@ import React from "react";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Script from "next/script";
+import Providers from "./providers";
import { AppLayout } from "./components";
+import { EarlyReady } from "./early-ready";
export const dynamic = "force-static";
@@ -23,6 +25,20 @@ export const metadata: Metadata = {
title: "Noblocks",
},
other: {
+ 'fc:miniapp': JSON.stringify({
+ version: 'next',
+ imageUrl: 'https://noblocks.xyz/icons/android-chrome-192x192.png',
+ button: {
+ title: `Launch Noblocks`,
+ action: {
+ type: 'launch_frame',
+ name: 'Noblocks',
+ url: 'https://noblocks.xyz',
+ splashImageUrl: 'https://noblocks.xyz/images/noblocks-bg-image.png',
+ splashBackgroundColor: '#8B85F4',
+ },
+ },
+ }),
"mobile-web-app-capable": "yes",
"msapplication-TileColor": "#317EFB",
"msapplication-tap-highlight": "no",
@@ -234,13 +250,16 @@ export default function RootLayout({
className={`${inter.className} overflow-x-hidden`}
suppressHydrationWarning
>
+
- {children}
+
+ {children}
+