terry-staging #16

Merged
terry merged 96 commits from terry-staging into main 2026-06-05 16:33:12 +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 { ToastProvider } from "./components/Toast";
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide"; import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
import { FavoritesProvider } from "./favorites/FavoritesProvider"; import { FavoritesProvider } from "./favorites/FavoritesProvider";
import { AutoInjectedLogin } from "./wallet/AutoInjectedLogin";
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider"; import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
import { WalletLoginModal } from "./wallet/WalletLoginModal"; import { WalletLoginModal } from "./wallet/WalletLoginModal";
import { WalletProvider } from "./wallet/WalletProvider"; import { WalletProvider } from "./wallet/WalletProvider";
import { WalletStackErrorBoundary } from "./wallet/WalletStackErrorBoundary";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
import { LocalizedHomePage } from "./pages/LocalizedHome"; import { LocalizedHomePage } from "./pages/LocalizedHome";
import { Browse } from "./pages/Browse"; import { Browse } from "./pages/Browse";
@@ -32,115 +34,113 @@ export default function App() {
<I18nProvider> <I18nProvider>
<MotionProvider> <MotionProvider>
<ToastProvider> <ToastProvider>
<RainbowWalletProvider> <WalletProvider>
<WalletProvider> <AutoInjectedLogin />
<FavoritesProvider> <WalletStackErrorBoundary>
<SaveToAlbumGuideProvider> <RainbowWalletProvider>
<AdminRouterModeProvider value="absolute"> <WalletLoginModal />
<ImageLightboxProvider> </RainbowWalletProvider>
<VideoPlayerProvider> </WalletStackErrorBoundary>
<PageTitleProvider> <FavoritesProvider>
<WalletLoginModal /> <SaveToAlbumGuideProvider>
<BrowserRouter> <AdminRouterModeProvider value="absolute">
<ScrollToTop /> <ImageLightboxProvider>
<Routes> <VideoPlayerProvider>
<Route element={<PublicLayout />}> <PageTitleProvider>
{/* English (root, no prefix) */} <BrowserRouter>
<Route <ScrollToTop />
path="/" <Routes>
element={ <Route element={<PublicLayout />}>
<LocalizedHomePage targetLang="en" /> {/* English (root, no prefix) */}
}
/>
<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 />}
/>
)}
<Route <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 />} element={<Navigate to="/" replace />}
/> />
</Routes> )}
</BrowserRouter>
</PageTitleProvider> <Route
</VideoPlayerProvider> path="*"
</ImageLightboxProvider> element={<Navigate to="/" replace />}
</AdminRouterModeProvider> />
</SaveToAlbumGuideProvider> </Routes>
</FavoritesProvider> </BrowserRouter>
</WalletProvider> </PageTitleProvider>
</RainbowWalletProvider> </VideoPlayerProvider>
</ImageLightboxProvider>
</AdminRouterModeProvider>
</SaveToAlbumGuideProvider>
</FavoritesProvider>
</WalletProvider>
</ToastProvider> </ToastProvider>
</MotionProvider> </MotionProvider>
</I18nProvider> </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 { LoaderCircle, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import type { WalletKind } from "./injected"; import { walletDeepLink } from "./deepLinks";
import { getInjectedWallet, type WalletKind } from "./injected";
import { useWallet } from "./WalletProvider"; import { useWallet } from "./WalletProvider";
import { useWalletConnectLogin } from "./useWalletConnectLogin"; import { useWalletConnectLogin } from "./useWalletConnectLogin";
import { WalletBrandIcon } from "./WalletBrandIcon"; 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"]; const wallets: WalletKind[] = ["tokenPocket", "metaMask", "imToken"];
function isMobileDevice(): boolean { function isMobileDevice(): boolean {
@@ -61,6 +74,19 @@ export function WalletLoginModal() {
void wc.start(kind, mode); 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 ( return (
<div <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" 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"> <div className="mt-3 grid grid-cols-2 gap-2">
<button <button
type="button" type="button"
onClick={() => startWalletLogin(kind, "deeplink")} onClick={() => openWalletAppDirect(kind)}
disabled={!wc.available || busy} 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" 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")} {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;
}
}