Skip to content
Closed
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
20 changes: 20 additions & 0 deletions app/src/app/ui-review-bridge/BridgeReview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { BRIDGE_QUOTE_DEBOUNCE_MS } from "@/lib/bridge/quote-session";
import styles from "@/components/bridge/BridgeDialog.module.css";
import { HistoryBoard } from "@/components/bridge/BridgeHistory";
import type { BridgeHistoryEntry } from "@/lib/bridge/history";
import { FundCard } from "@/components/bridge/BridgeToBuy";
import type { BridgeHolding, FundStep } from "@/lib/bridge/fund";
import type { BridgeAsset, BridgeDestinationId } from "@/lib/bridge/types";

const wallet = "0x03508bB71268BBA25ECaCC8F620e01866650532c" as const;
const requestId = `0x${"1".repeat(64)}` as const;
Expand All @@ -35,6 +38,18 @@ const HISTORY_ROWS: BridgeHistoryEntry[] = [
type HistoryScene = "rows" | "empty" | "loading" | "no wallet" | "unsaved";
const HISTORY_SCENES: HistoryScene[] = ["rows", "empty", "loading", "no wallet", "unsaved"];

// "Bridge, then buy" in the trade panel, one card per step it can show.
type FundScene = { name: string; step: FundStep; destination: BridgeDestinationId; asset: BridgeAsset; symbol: string; row: BridgeHistoryEntry | null; shortBy: string | null; suggestion: BridgeHolding | null; searching: boolean };
const FUND_SCENES: FundScene[] = [
{ name: "short, source found", step: "short", destination: 4663, asset: "ETH", symbol: "ORBIT", row: null, shortBy: "0.0125 ETH", suggestion: { chainId: 8453, asset: "USDC", amount: 120_500_000n, decimals: 6 }, searching: false },
{ name: "short, checking", step: "short", destination: 4663, asset: "ETH", symbol: "ORBIT", row: null, shortBy: "0.0125 ETH", suggestion: null, searching: true },
{ name: "empty, nothing elsewhere", step: "short", destination: 5042, asset: "USDC", symbol: "ARCO", row: null, shortBy: null, suggestion: null, searching: false },
{ name: "moving", step: "moving", destination: 4663, asset: "ETH", symbol: "ORBIT", row: { ...HISTORY_ROWS[0], originChainId: 42161, createdAt: minutesAgo(1) }, shortBy: null, suggestion: null, searching: false },
{ name: "delayed", step: "moving", destination: 5042, asset: "USDC", symbol: "ARCO", row: HISTORY_ROWS[3], shortBy: null, suggestion: null, searching: false },
{ name: "arrived", step: "arrived", destination: 8453, asset: "USDC", symbol: "BASED", row: HISTORY_ROWS[1], shortBy: null, suggestion: null, searching: false },
{ name: "refunded", step: "attention", destination: 5042, asset: "USDC", symbol: "ARCO", row: HISTORY_ROWS[4], shortBy: null, suggestion: null, searching: false },
];

export default function BridgeReview() {
const [historyScene, setHistoryScene] = useState<HistoryScene>("rows");
const [open, setOpen] = useState(false);
Expand Down Expand Up @@ -110,6 +125,11 @@ export default function BridgeReview() {
</main>
<div className="mx-auto max-w-6xl px-5 pb-24">
<HistoryBoard wallet={historyScene === "no wallet" ? "" : wallet.toLowerCase()} rows={historyScene === "rows" || historyScene === "unsaved" ? HISTORY_ROWS : []} loading={historyScene === "loading"} unsaved={historyScene === "unsaved"} note="Preview only. Synthetic transfers, no wallet and no Relay call." onRefresh={() => {}} />
<h2 className="mt-16 text-lg font-semibold">Bridge, then buy</h2>
<p className="mt-2 text-sm text-body">The trade panel card, at trade panel width. Buttons do nothing here.</p>
<div className="mt-6 grid gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,340px),1fr))]">
{FUND_SCENES.map(({ name, ...scene }) => <figure key={name} className="m-0 grid min-w-0 content-start gap-2"><figcaption className="text-xs text-muted">{name}</figcaption><FundCard {...scene} onBridge={() => {}} onOpen={() => {}} onDismiss={() => {}} /></figure>)}
</div>
</div>
</>
);
Expand Down
6 changes: 4 additions & 2 deletions app/src/components/bridge/BridgeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,19 +171,21 @@ function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: Brid
* slot (bridge-slot.ts), inside that page instead: the same hook either way, so a transfer is quoted, sent and tracked
* once. `link` is the page's shareable route (lib/bridge/link.ts); it only preselects networks and tokens.
*/
export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = null, link = null }: {
export default function BridgeDialog({ open, onOpenChange, restoreFocus, slot = null, link = null, linkCount = 0 }: {
open: boolean;
onOpenChange: (open: boolean) => void;
restoreFocus: () => HTMLElement | null;
slot?: HTMLElement | null;
link?: BridgeLink | null;
/** Bumped by each request for a route, so asking for the same route again re-applies it. */
linkCount?: number;
}) {
const onPage = slot !== null;
const bridge = useBridge(open || onPage);
const popup = useRef<HTMLDivElement>(null);
const [connecting, setConnecting] = useState(false);
const transferring = bridge.tracked !== null;
const linkKey = bridgeLinkKey(link);
const linkKey = link ? `${linkCount}:${bridgeLinkKey(link)}` : "";
const appliedLink = useRef("");

// Each link is applied once, through the same guarded setters the pickers use: they refuse while an approval or a
Expand Down
37 changes: 28 additions & 9 deletions app/src/components/bridge/BridgeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,72 @@
"use client";

import dynamic from "next/dynamic";
import { createContext, useCallback, useContext, useRef, useState, useSyncExternalStore } from "react";
import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { ArrowLeftRight } from "lucide-react";
import { bridgeSlot } from "./bridge-slot";
import type { BridgeLink } from "@/lib/bridge/link";
import styles from "./BridgeDialog.module.css";

const BridgeDialog = dynamic(() => import("./BridgeDialog"), { ssr: false });
const BridgeContext = createContext<(() => void) | null>(null);
type OpenBridge = (link?: BridgeLink) => void;
const BridgeContext = createContext<{ show: OpenBridge; wake: () => void } | null>(null);

/** Mounted once for both navigation variants and for the /bridge page; closing the panel does not stop tracking. */
export function BridgeProvider({ children }: { children: React.ReactNode }) {
const [activated, setActivated] = useState(false);
const [open, setOpen] = useState(false);
// a route asked for by the caller (the token page's "bridge, then buy"); the count makes a repeat request apply again
const [requested, setRequested] = useState<{ link: BridgeLink; count: number } | null>(null);
const trigger = useRef<HTMLElement | null>(null);
// the /bridge page's slot: while it exists the panel lives in the page, and the dialog stays shut
const slot = useSyncExternalStore(bridgeSlot.subscribe, bridgeSlot.getSnapshot, bridgeSlot.getServerSnapshot);
if (slot.node && open) setOpen(false);
const show = useCallback(() => {
// the page's own route takes over; leaving the page must not bring back an older request
if (slot.node && requested) setRequested(null);
const show = useCallback((link?: BridgeLink) => {
const page = bridgeSlot.getSnapshot().node;
if (page) {
page.scrollIntoView({ block: "nearest" });
page.querySelector<HTMLElement>("#bridge-amount")?.focus({ preventScroll: true });
return;
}
trigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
if (link) setRequested((prior) => ({ link, count: (prior?.count ?? 0) + 1 }));
setActivated(true);
setOpen(true);
}, []);
// Mount the controller without opening anything, so a transfer saved in this browser keeps being tracked while the
// buyer stays on a token page. Tracking never asks the wallet for anything.
const wake = useCallback(() => setActivated(true), []);
const value = useMemo(() => ({ show, wake }), [show, wake]);
const restoreFocus = useCallback(() => trigger.current?.isConnected
? trigger.current
: document.querySelector<HTMLButtonElement>('[aria-controls="mobile-menu"]'), []);

return (
<BridgeContext.Provider value={show}>
<BridgeContext.Provider value={value}>
{children}
{activated || slot.node ? <BridgeDialog open={open} onOpenChange={setOpen} restoreFocus={restoreFocus} slot={slot.node} link={slot.link} /> : null}
{activated || slot.node ? <BridgeDialog open={open} onOpenChange={setOpen} restoreFocus={restoreFocus} slot={slot.node} link={slot.node ? slot.link : requested?.link ?? null} linkCount={slot.node ? 0 : requested?.count ?? 0} /> : null}
</BridgeContext.Provider>
);
}

/** Opens the bridge panel from anywhere inside the provider (the ⌘K search uses it). Null outside it. */
export function useOpenBridge(): (() => void) | null {
return useContext(BridgeContext);
/**
* Opens the bridge panel from anywhere inside the provider (the ⌘K search uses it), optionally on a route. The route
* goes through the panel's guarded pickers, so it never changes a transfer or approval in progress. Null outside it.
*/
export function useOpenBridge(): OpenBridge | null {
return useContext(BridgeContext)?.show ?? null;
}

/** Starts background tracking of a saved transfer without opening the panel. Null outside the provider. */
export function useWakeBridge(): (() => void) | null {
return useContext(BridgeContext)?.wake ?? null;
}

/** `compact` drops the label (icon only) where the header has no room: the whole desktop strip, resting or floating. */
export function BridgeButton({ block = false, compact = false, onOpen }: { block?: boolean; compact?: boolean; onOpen?: () => void }) {
const show = useContext(BridgeContext);
const show = useContext(BridgeContext)?.show;
return (
<button type="button" className={`${styles.trigger} ${block ? styles.blockTrigger : ""} ${compact && !block ? styles.compactTrigger : ""}`} aria-label="Bridge funds" aria-haspopup="dialog" title="Bridge to Base, Robinhood and Arc, or fund them from Ethereum, Arbitrum and Optimism" onClick={() => { show?.(); onOpen?.(); }}>
<ArrowLeftRight size={17} strokeWidth={1.8} aria-hidden />
Expand Down
Loading
Loading