diff --git a/app/src/app/bridge/bridge.module.css b/app/src/app/bridge/bridge.module.css new file mode 100644 index 00000000..c9dee357 --- /dev/null +++ b/app/src/app/bridge/bridge.module.css @@ -0,0 +1,27 @@ +/* /bridge: the panel on the left at a comfortable reading width, the facts beside it; one column on small screens with the panel first. */ +.layout { display: grid; grid-template-columns: minmax(0, 520px) minmax(0, 1fr); align-items: start; gap: 40px 56px; } +.facts { display: grid; gap: 32px; min-width: 0; padding-top: 6px; } +.facts h2 { margin: 0 0 12px; color: var(--color-ink); font-size: 17px; font-weight: 650; letter-spacing: -.02em; line-height: 1.3; } + +.networks { display: grid; margin: 0; } +.networks div { display: grid; grid-template-columns: minmax(96px, 132px) minmax(0, 1fr); gap: 12px; padding: 10px 0; border-top: 1px solid var(--color-line); font-size: 14px; line-height: 1.5; } +.networks div:last-child { border-bottom: 1px solid var(--color-line); } +.networks dt { color: var(--color-ink); font-weight: 600; } +.networks dd { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; margin: 0; color: var(--color-body); } +.networks dd span { color: var(--color-muted); font-size: 13px; } + +/* a real sequence: each step happens after the one before it */ +.steps { display: grid; gap: 14px; margin: 0; padding: 0; list-style: none; counter-reset: step; } +.steps li { display: grid; grid-template-columns: 26px minmax(0, 1fr); column-gap: 12px; counter-increment: step; font-size: 14px; line-height: 1.6; } +.steps li::before { content: counter(step); grid-row: span 2; display: grid; place-items: center; width: 26px; height: 26px; border: 1px solid var(--color-line-strong); border-radius: 50%; color: var(--color-ink); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; } +.steps strong { color: var(--color-ink); font-weight: 600; } +.steps span { color: var(--color-body); } + +.points { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; color: var(--color-body); font-size: 14px; line-height: 1.6; } +.points li { position: relative; padding-left: 16px; } +.points li::before { content: ""; position: absolute; left: 0; top: .68em; width: 5px; height: 5px; border-radius: 50%; background: var(--color-line-strong); } + +@media (max-width: 960px) { + .layout { grid-template-columns: minmax(0, 1fr); } + .layout > :first-child { max-width: 560px; } +} diff --git a/app/src/app/bridge/page.tsx b/app/src/app/bridge/page.tsx new file mode 100644 index 00000000..f93d02df --- /dev/null +++ b/app/src/app/bridge/page.tsx @@ -0,0 +1,76 @@ +import type { Metadata } from "next"; +import { ArrowLeftRight } from "lucide-react"; +import { pageMetadata } from "@/lib/seo"; +import SectionIntro from "@/components/sections/SectionIntro"; +import shell from "@/components/sections/SectionShell.module.css"; +import BridgePageSlot from "@/components/bridge/BridgePageSlot"; +import { parseBridgeLink } from "@/lib/bridge/link"; +import { BRIDGE_ASSETS, BRIDGE_CHAINS, BRIDGE_DESTINATION_IDS, BRIDGE_FUNDING_IDS } from "@/lib/bridge/types"; +import styles from "./bridge.module.css"; + +/** + * The bridge as a page: the same panel the header opens, with room for the facts beside it. The panel is rendered by + * the header's one bridge controller into the slot below (components/bridge/bridge-slot.ts), so this page starts no + * second copy. `?from=…&to=…&asset=…&toAsset=…` preselects a route (lib/bridge/link.ts) and nothing more. + */ +export const metadata: Metadata = pageMetadata({ + path: "/bridge", + title: "Bridge", + description: "Move ETH and dollars between Base, Robinhood Chain and Arc, or fund them from Ethereum, Arbitrum and Optimism. Same wallet, no openlaunch fee.", +}); + +const STEPS = [ + ["Pick a route and an amount", "A live quote appears with the fee, the gas and the least you will receive. Nothing is asked of your wallet yet."], + ["Approve, for tokens only", "USDC and USDG need an approval for the exact amount first. It is its own wallet step and moves nothing. ETH skips it."], + ["Confirm the deposit", "Slide the coin across or press the button, then confirm in your wallet. This is the one step that sends funds."], + ["Watch it arrive", "The board follows the transfer until the provider confirms delivery to the same address on the other network."], +] as const; + +export default async function BridgePage({ searchParams }: { searchParams: Promise> }) { + const link = parseBridgeLink(await searchParams); + return ( +
+ } + title="Move funds to where you launch" + description="Send ETH and dollars between Base, Robinhood Chain and Arc, or bring them in from Ethereum, Arbitrum and Optimism. They arrive at the same wallet address. openlaunch charges nothing for it." + /> +
+ +
+
+

Networks and tokens

+
+ {BRIDGE_DESTINATION_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{BRIDGE_ASSETS[id].join(", ")}Send and receive
)} + {BRIDGE_FUNDING_IDS.map((id) =>
{BRIDGE_CHAINS[id].name}
{BRIDGE_ASSETS[id].join(", ")}Send only
)} +
+
+
+

How a transfer goes

+
    + {STEPS.map(([title, body]) =>
  1. {title}{body}
  2. )} +
+
+
+

What it costs

+
    +
  • openlaunch takes no fee on a transfer.
  • +
  • The bridge provider, Relay, charges a fee that is shown before you confirm and is already taken out of the amount you receive.
  • +
  • Gas on the network you send from is extra.
  • +
  • A quote that would cost more than 5% in fees, or lose more than 5% in conversion, is blocked.
  • +
+
+
+

Good to know

+
    +
  • Funds go from your wallet to Relay's deposit contract. They never pass through an openlaunch wallet or contract.
  • +
  • Relay is a third-party protocol. openlaunch does not operate it and cannot reverse a transfer.
  • +
  • A transfer is saved in this browser while it is in progress. Reopen the bridge with the same wallet to pick it up.
  • +
+
+
+
+
+ ); +} diff --git a/app/src/app/metadata.test.ts b/app/src/app/metadata.test.ts index 55231705..b02a4180 100644 --- a/app/src/app/metadata.test.ts +++ b/app/src/app/metadata.test.ts @@ -26,6 +26,7 @@ test("every indexable page declares its own canonical path and share card", () = ["./feed/page.tsx", "/feed"], ["./agents/page.tsx", "/agents"], ["./about/page.tsx", "/about"], + ["./bridge/page.tsx", "/bridge"], ]; for (const [file, path] of pages) { const src = read(file); diff --git a/app/src/components/Footer.tsx b/app/src/components/Footer.tsx index c88868c5..d2a07c73 100644 --- a/app/src/components/Footer.tsx +++ b/app/src/components/Footer.tsx @@ -10,6 +10,7 @@ import styles from "./Footer.module.css"; const EXPLORE = [ { href: "/", label: "Launchpad" }, { href: "/rules", label: "How it works" }, + { href: "/bridge", label: "Bridge" }, { href: "/about", label: "About openlaunch" }, { href: "/feed", label: "Community posts" }, { href: "/me", label: "Your dashboard" }, diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index 24f2156d..aabd1476 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -53,7 +53,7 @@ .reverse { display: inline-flex; align-items: center; justify-content: center; height: 36px; width: 36px; margin-top: 4px; color: var(--color-muted); border: 1px solid var(--color-line); border-radius: 50%; cursor: pointer; transition: transform 180ms cubic-bezier(.16, 1, .3, 1), background 160ms, color 160ms; } .amountSection { padding: 23px 0 20px; } .amountRow { display: flex; align-items: center; gap: 16px; margin-top: 4px; } -.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-size: 38px; line-height: 1.35; letter-spacing: -.035em; font-variant-numeric: tabular-nums; font-weight: 500; } +.amountRow input { display: block; min-width: 0; width: 100%; border: 0; border-radius: 4px; background: transparent; color: var(--color-ink); caret-color: var(--color-brand); font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 40px; line-height: 1.3; letter-spacing: 0; font-variant-numeric: tabular-nums; font-weight: 800; } .amountRow input::placeholder { color: var(--color-muted); } .currency { display: inline-flex; flex-shrink: 0; align-items: center; gap: 5px; font-size: 18px; font-weight: 550; } .assetTrigger { display: inline-flex; flex-shrink: 0; align-items: center; justify-content: center; gap: 7px; min-height: 44px; padding: 6px 9px; border: 1px solid var(--color-line-strong); border-radius: 12px; background: var(--color-paper); color: var(--color-ink); font-size: 16px; font-weight: 550; cursor: pointer; transition: background 160ms ease, border-color 160ms ease, transform 120ms ease-out; } @@ -93,8 +93,8 @@ .quote { padding: 18px 0 0; border-top: 1px solid var(--color-line); } .receiveLabel { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 12px; color: var(--color-body); } .estimate { font-size: 11px; color: var(--color-muted); } -.receiveAmount { margin: 6px 0 18px; font-size: 27px; font-weight: 600; line-height: 1.3; letter-spacing: -.025em; font-variant-numeric: tabular-nums; } -.receiveAmount span { display: inline-flex; align-items: center; gap: 4px; margin-left: 6px; vertical-align: middle; font-size: 16px; color: var(--color-muted); font-weight: 500; } +.receiveAmount { margin: 6px 0 18px; font-family: var(--font-dots), var(--font-mono); font-variation-settings: "ROND" 100; font-size: 30px; font-weight: 800; line-height: 1.3; letter-spacing: 0; font-variant-numeric: tabular-nums; } +.receiveAmount span { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; vertical-align: middle; font-family: var(--font-sans); font-size: 16px; color: var(--color-muted); font-weight: 500; } .fees { display: grid; gap: 9px; font-size: 12px; } .fees > div, .transferDetails > div { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; } .fees dt, .transferDetails dt { color: var(--color-body); } @@ -116,6 +116,9 @@ .primary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 48px; width: 100%; padding: 12px 16px; border: 1px solid transparent; border-radius: 12px; background: var(--color-brand); color: var(--color-inverse); font-size: 14px; font-weight: 600; cursor: pointer; transition: background 160ms, transform 160ms cubic-bezier(.16, 1, .3, 1); } .panel button:disabled { opacity: .55; cursor: not-allowed; } .primary:disabled { opacity: 1; background: var(--color-paper); border-color: var(--color-line); color: var(--color-muted); } +/* beside the slide-to-confirm rail the ordinary button steps back, but stays a full-size, plainly labelled control */ +.secondary { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 44px; width: 100%; margin-top: 8px; padding: 10px 16px; border: 1px solid var(--color-line-strong); border-radius: 12px; background: transparent; color: var(--color-ink); font-size: 13px; font-weight: 600; cursor: pointer; transition: background 160ms ease, transform 160ms cubic-bezier(.16, 1, .3, 1); } +.secondary:disabled { color: var(--color-muted); cursor: default; } .disclaimer { margin-top: 12px; color: var(--color-muted); font-size: 11px; line-height: 1.65; } .footer { display: flex; justify-content: space-between; gap: 12px; padding-top: 16px; margin-top: 20px; border-top: 1px solid var(--color-line); font-size: 11px; color: var(--color-muted); } .footer a, .explorerLinks a { display: inline-flex; align-items: center; gap: 3px; text-underline-offset: 3px; } @@ -163,4 +166,4 @@ .amountRow input { font-size: 34px; } } @media (max-width: 360px) { .networkTrigger { grid-template-columns: 24px minmax(0, 1fr) 13px; gap: 5px; padding-inline: 4px; } .networkTrigger .networkMark { width: 24px; height: 24px; } .networkTrigger .networkMark img { max-width: 24px; max-height: 24px; } .networkTrigger .networkName { font-size: 13px; } } -@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger { transition: none; } .spinner { animation: none; } } +@media (prefers-reduced-motion: reduce) { .panel, .backdrop, .primary, .secondary, .trigger, .reverse, .networkTrigger, .networkPopup, .assetTrigger { transition: none; } .spinner { animation: none; } } diff --git a/app/src/components/bridge/BridgeDialog.tsx b/app/src/components/bridge/BridgeDialog.tsx index aa742a77..e12872ee 100644 --- a/app/src/components/bridge/BridgeDialog.tsx +++ b/app/src/components/bridge/BridgeDialog.tsx @@ -1,19 +1,27 @@ "use client"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import Image from "next/image"; +import Link from "next/link"; import { Dialog } from "@base-ui/react/dialog"; import { Select } from "@base-ui/react/select"; import { ArrowLeftRight, ArrowRight, ArrowUpRight, Check, ChevronDown, ChevronRight, CircleAlert, CircleDollarSign, Clock3, LoaderCircle, Wallet, X } from "lucide-react"; import { formatEther, formatUnits, zeroAddress } from "viem"; import { BRIDGE_ASSETS, BRIDGE_ASSET_NAMES, BRIDGE_CHAINS, BRIDGE_CHAIN_IDS, BRIDGE_DESTINATION_IDS, bridgeCurrency, bridgeTokenAsset, bridgeTransferInputCurrency, isBridgeAssetSupported, isBridgeChainId, isBridgeFundingChain, type BridgeAsset, type BridgeChainId, type BridgeQuoteRejection } from "@/lib/bridge/types"; import { DISCARD_AFTER_MS } from "@/lib/bridge/client"; +import { bridgeLinkKey, type BridgeLink } from "@/lib/bridge/link"; +import { signalCaption, signalMode } from "@/lib/bridge/signal"; import { formatFeeWarningPercent } from "@/lib/bridge/fee-display"; import { shortAddr } from "@/lib/chainPublic"; import WalletPicker from "../WalletPicker"; import WalletAvatar from "../WalletAvatar"; import useBridge from "./useBridge"; +import SignalBoard from "./SignalBoard"; +import SlideConfirm from "./SlideConfirm"; +import { dotFont } from "./dot-font"; import styles from "./BridgeDialog.module.css"; +import signal from "./Signal.module.css"; type Bridge = ReturnType; // Money leaves from any network, funding ones included, but arrives only on a launch chain. @@ -158,24 +166,55 @@ function FeeLimitNotice({ rejection, inputSymbol, gasSymbol }: { rejection: Brid ; } -export default function BridgeDialog({ open, onOpenChange, restoreFocus }: { +/** + * The bridge's one controller. It shows the panel as the header dialog, or, when the /bridge page has registered its + * 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 }: { open: boolean; onOpenChange: (open: boolean) => void; restoreFocus: () => HTMLElement | null; + slot?: HTMLElement | null; + link?: BridgeLink | null; }) { - const bridge = useBridge(open); + const onPage = slot !== null; + const bridge = useBridge(open || onPage); const popup = useRef(null); const [connecting, setConnecting] = useState(false); const transferring = bridge.tracked !== null; + const linkKey = 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 + // transfer is in progress, so a link can never move a route out from under one. + useEffect(() => { + if (!link || appliedLink.current === linkKey) return; + appliedLink.current = linkKey; + bridge.setOriginChainId(link.originChainId); + bridge.setDestinationChainId(link.destinationChainId); + if (link.originAsset) bridge.setOriginAsset(link.originAsset); + if (link.destinationAsset) bridge.setDestinationAsset(link.destinationAsset); + }, [bridge, link, linkKey]); function connect() { - onOpenChange(false); + if (!onPage) onOpenChange(false); setConnecting(true); } + const footer = ( +
+ Powered by Relay + {onPage ? null : onOpenChange(false)}>Open full page} + 0 Openlaunch fee +
+ ); + const content = transferring ? : ; + return ( <> - + {onPage ? createPortal(
{content}{footer}
, slot) : null} + { if (event.key === "Escape") event.stopPropagation(); }}> @@ -184,15 +223,12 @@ export default function BridgeDialog({ open, onOpenChange, restoreFocus }: { {transferring ? "Your transfer, from departure to arrival." : "Same wallet. A new network."} - {transferring ? : } -
- Powered by Relay - 0 Openlaunch fee -
+ {content} + {footer}
- {connecting ? { setConnecting(false); onOpenChange(true); }} /> : null} + {connecting ? { setConnecting(false); if (!onPage) onOpenChange(true); }} /> : null} ); } @@ -219,14 +255,20 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () : reviewing && b.approvalRequired ? `Approve ${nativeAmount(b.amount)} ${inputCurrency.symbol}` : reviewing ? `Bridge to ${destination.name}` : b.quoteError ? "Retry quote" : !b.canQuote ? "Enter an amount" : "Get quote"; const error = (!b.quoteRejection && b.quoteError) || b.error || b.storageError || b.approvalError; + const mode = signalMode({ phase: b.phase, tracked: false, approvalActive: b.approvalBusy, quoteLoading: b.quoteLoading, reviewing, quoteExpired: needsRefresh, blocked: !!b.quoteRejection || !!b.quoteError }); + // the deposit, and only the deposit, can also be confirmed by sliding: never an approval, a quote or a refresh + const slideReady = !!b.address && reviewing && !b.approvalRequired && !loading && !b.allowanceLoading; + + // the button and the slide share this one path, with the same guards + function submit() { + if (!b.address) { connect(); return; } + if (loading || b.allowanceLoading || !b.canQuote) return; + void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); + } return ( -
{ - event.preventDefault(); - if (!b.address) { connect(); return; } - if (loading || b.allowanceLoading || !b.canQuote) return; - void (reviewing ? b.approvalRequired ? b.approve() : b.confirm() : b.requestQuote()); - }}> + { event.preventDefault(); submit(); }}> +
@@ -271,7 +313,8 @@ export function BridgeForm({ bridge: b, connect }: { bridge: Bridge; connect: () {b.address ? : null} {error ?

{error}

: null} -