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 > +