terry-wallet-login #15

Merged
terry merged 95 commits from terry-wallet-login into terry-staging 2026-06-05 16:32:43 +00:00
4 changed files with 242 additions and 109 deletions
Showing only changes of commit 6800a8e9b6 - Show all commits

View File

@@ -4,9 +4,11 @@ import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast";
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
import { FavoritesProvider } from "./favorites/FavoritesProvider";
import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin";
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
import { WalletLoginModal } from "./wallet/WalletLoginModal";
import { WalletProvider } from "./wallet/WalletProvider";
import { WalletStackErrorBoundary } from "./wallet/WalletStackErrorBoundary";
import { PublicLayout } from "./layouts/PublicLayout";
import { LocalizedHomePage } from "./pages/LocalizedHome";
import { Browse } from "./pages/Browse";
@@ -32,115 +34,113 @@ export default function App() {
<I18nProvider>
<MotionProvider>
<ToastProvider>
<RainbowWalletProvider>
<WalletProvider>
<FavoritesProvider>
<SaveToAlbumGuideProvider>
<AdminRouterModeProvider value="absolute">
<ImageLightboxProvider>
<VideoPlayerProvider>
<PageTitleProvider>
<WalletLoginModal />
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route element={<PublicLayout />}>
{/* English (root, no prefix) */}
<Route
path="/"
element={
<LocalizedHomePage targetLang="en" />
}
/>
<Route path="/browse" element={<Browse />} />
<Route
path="/categories"
element={<CategoriesPage />}
/>
<Route
path="/official-recommendations"
element={<OfficialRecommendationsPage />}
/>
<Route
path="/category/:slug"
element={<CategoryPage />}
/>
<Route
path="/search"
element={<SearchPage />}
/>
<Route
path="/resource/:id"
element={<PostRedirect />}
/>
<Route
path="/favorites"
element={<Favorites />}
/>
{/* Each non-English language gets its own nested tree. */}
{localizedHomeRoutes.map((route) => (
<Route key={route.path} path={route.path}>
<Route
index
element={
<LocalizedHomePage
targetLang={route.lang}
/>
}
/>
<Route path="browse" element={<Browse />} />
<Route
path="categories"
element={<CategoriesPage />}
/>
<Route
path="official-recommendations"
element={<OfficialRecommendationsPage />}
/>
<Route
path="category/:slug"
element={<CategoryPage />}
/>
<Route
path="search"
element={<SearchPage />}
/>
<Route
path="resource/:id"
element={<PostRedirect />}
/>
<Route
path="favorites"
element={<Favorites />}
/>
</Route>
))}
</Route>
{adminEnabled ? (
AdminRouteTree()
) : (
<Route
path={`${adminUiPrefix}/*`}
element={<Navigate to="/" replace />}
/>
)}
<WalletProvider>
<AutoInjectedLogin />
<WalletStackErrorBoundary>
<RainbowWalletProvider>
<WalletLoginModal />
</RainbowWalletProvider>
</WalletStackErrorBoundary>
<FavoritesProvider>
<SaveToAlbumGuideProvider>
<AdminRouterModeProvider value="absolute">
<ImageLightboxProvider>
<VideoPlayerProvider>
<PageTitleProvider>
<BrowserRouter>
<ScrollToTop />
<Routes>
<Route element={<PublicLayout />}>
{/* English (root, no prefix) */}
<Route
path="*"
path="/"
element={<LocalizedHomePage targetLang="en" />}
/>
<Route path="/browse" element={<Browse />} />
<Route
path="/categories"
element={<CategoriesPage />}
/>
<Route
path="/official-recommendations"
element={<OfficialRecommendationsPage />}
/>
<Route
path="/category/:slug"
element={<CategoryPage />}
/>
<Route path="/search" element={<SearchPage />} />
<Route
path="/resource/:id"
element={<PostRedirect />}
/>
<Route
path="/favorites"
element={<Favorites />}
/>
{/* Each non-English language gets its own nested tree. */}
{localizedHomeRoutes.map((route) => (
<Route key={route.path} path={route.path}>
<Route
index
element={
<LocalizedHomePage
targetLang={route.lang}
/>
}
/>
<Route path="browse" element={<Browse />} />
<Route
path="categories"
element={<CategoriesPage />}
/>
<Route
path="official-recommendations"
element={<OfficialRecommendationsPage />}
/>
<Route
path="category/:slug"
element={<CategoryPage />}
/>
<Route
path="search"
element={<SearchPage />}
/>
<Route
path="resource/:id"
element={<PostRedirect />}
/>
<Route
path="favorites"
element={<Favorites />}
/>
</Route>
))}
</Route>
{adminEnabled ? (
AdminRouteTree()
) : (
<Route
path={`${adminUiPrefix}/*`}
element={<Navigate to="/" replace />}
/>
</Routes>
</BrowserRouter>
</PageTitleProvider>
</VideoPlayerProvider>
</ImageLightboxProvider>
</AdminRouterModeProvider>
</SaveToAlbumGuideProvider>
</FavoritesProvider>
</WalletProvider>
</RainbowWalletProvider>
)}
<Route
path="*"
element={<Navigate to="/" replace />}
/>
</Routes>
</BrowserRouter>
</PageTitleProvider>
</VideoPlayerProvider>
</ImageLightboxProvider>
</AdminRouterModeProvider>
</SaveToAlbumGuideProvider>
</FavoritesProvider>
</WalletProvider>
</ToastProvider>
</MotionProvider>
</I18nProvider>

View File

@@ -0,0 +1,83 @@
import { useEffect } from "react";
import {
connectInjectedWallet,
getInjectedWallet,
type WalletKind,
} from "./injected";
import { localWalletToken, useWallet } from "./WalletProvider";
const AUTO_LOGIN_PARAM = "autoLogin";
const ETHEREUM_WAIT_MS = 8000;
const ETHEREUM_POLL_MS = 200;
function parseKind(value: string | null): WalletKind | null {
if (value === "tokenPocket" || value === "metaMask" || value === "imToken") {
return value;
}
return null;
}
function stripAutoLoginParam(): void {
const url = new URL(window.location.href);
url.searchParams.delete(AUTO_LOGIN_PARAM);
const qs = url.searchParams.toString();
const next = url.pathname + (qs ? `?${qs}` : "") + url.hash;
window.history.replaceState({}, "", next);
}
function waitForInjected(kind: WalletKind): Promise<boolean> {
return new Promise((resolve) => {
const start = Date.now();
const tick = () => {
if (getInjectedWallet(kind)) {
resolve(true);
return;
}
if (Date.now() - start >= ETHEREUM_WAIT_MS) {
resolve(false);
return;
}
window.setTimeout(tick, ETHEREUM_POLL_MS);
};
tick();
});
}
/**
* When the page is opened via a `?autoLogin=<wallet>` deeplink (typically from
* inside TokenPocket / imToken in-app browsers), wait for the wallet to inject
* `window.ethereum`, then complete a local wallet session automatically. Bypasses
* WalletConnect entirely so it works on networks where the WC relay is blocked.
*/
export function AutoInjectedLogin() {
const { completeLogin, status } = useWallet();
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const kind = parseKind(params.get(AUTO_LOGIN_PARAM));
if (!kind) return;
stripAutoLoginParam();
if (status === "loggedIn") return;
let cancelled = false;
void waitForInjected(kind).then(async (ready) => {
if (cancelled || !ready) return;
try {
const address = await connectInjectedWallet(kind);
if (cancelled) return;
completeLogin(localWalletToken(address), address);
} catch (err) {
// eslint-disable-next-line no-console
console.warn("[wallet-autologin] failed", err);
}
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}

View File

@@ -2,11 +2,24 @@ import { QRCodeSVG } from "qrcode.react";
import { LoaderCircle, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useI18n } from "../i18n";
import type { WalletKind } from "./injected";
import { walletDeepLink } from "./deepLinks";
import { getInjectedWallet, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider";
import { useWalletConnectLogin } from "./useWalletConnectLogin";
import { WalletBrandIcon } from "./WalletBrandIcon";
const AUTO_LOGIN_PARAM = "autoLogin";
function supportsDirectPull(kind: WalletKind): boolean {
return kind === "tokenPocket" || kind === "imToken";
}
function buildAutoLoginDappUrl(kind: WalletKind): string {
const url = new URL(window.location.href);
url.searchParams.set(AUTO_LOGIN_PARAM, kind);
return url.toString();
}
const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
function isMobileDevice(): boolean {
@@ -61,6 +74,19 @@ export function WalletLoginModal() {
void wc.start(kind, mode);
};
const openWalletAppDirect = (kind: WalletKind) => {
if (getInjectedWallet(kind)) {
startWalletLogin(kind, "deeplink");
return;
}
if (mobileDevice && supportsDirectPull(kind)) {
const deeplink = walletDeepLink(kind, buildAutoLoginDappUrl(kind));
window.location.href = deeplink;
return;
}
startWalletLogin(kind, "deeplink");
};
return (
<div
className="fixed inset-0 z-[120] flex items-end justify-center bg-black/70 px-3 pb-3 pt-10 backdrop-blur-sm md:items-center md:p-6"
@@ -137,8 +163,10 @@ export function WalletLoginModal() {
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => startWalletLogin(kind, "deeplink")}
disabled={!wc.available || busy}
onClick={() => openWalletAppDirect(kind)}
disabled={
supportsDirectPull(kind) ? busy : !wc.available || busy
}
className="rounded-full bg-ark-gold px-3 py-2 text-sm font-bold text-black transition hover:bg-ark-gold2 disabled:cursor-wait disabled:opacity-70"
>
{t("walletOpenWalletApp")}

View File

@@ -0,0 +1,22 @@
import { Component, type ReactNode } from "react";
type Props = { children: ReactNode; fallback?: ReactNode };
type State = { hasError: boolean };
export class WalletStackErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: unknown): void {
// eslint-disable-next-line no-console
console.error("[wallet-stack] error boundary caught", error);
}
render(): ReactNode {
if (this.state.hasError) return this.props.fallback ?? null;
return this.props.children;
}
}