diff --git a/packages/widget/ARCHITECTURE.md b/packages/widget/ARCHITECTURE.md index 6506fef83..c6229d356 100644 --- a/packages/widget/ARCHITECTURE.md +++ b/packages/widget/ARCHITECTURE.md @@ -43,6 +43,29 @@ Widget root, a prop the Layout's composition supplies that names the difference, or a Layout-specific component. See [ADR 0013](../../docs/adr/0013-layouts-are-application-composition.md). +`app/layouts/layout-app-container.tsx` selects the Layout attribute and page +transition. The shared shell receives that resolved presentation; it does not +select a Layout or own Layout-specific transition presets. + +## Routes and navigation + +The Route Registry (`services/navigation/widget-routes.ts`) defines every +absolute Widget path once: its pattern, typed params, the Layouts that mount +it, how to build it, and how to match a pathname against it. Route trees mount +registry patterns, navigation accepts only registry-built `WidgetPath`s, and +pathname parsing uses registry matching; only relative redirects stay literal. +Building a route that some Layout does not mount requires naming the Layout. A +Transaction Flow's Review, Steps, and Complete form one flow group per mount. +The owning Feature or application composition binds the navigation-state schema +to its route or flow group. Presentation changes carried by navigation apply +only after the router accepts it, so blocked navigation cannot remount the +current page. The `navigation` Feature adapts the registry to React. + +Navigation that completes a domain operation, such as a handoff with its +navigation or a deep link, runs in that Effect operation through the +`WidgetNavigation` service. Navigation that only answers a user's UI action, +such as a tab press or a back button, stays in React. + ## Dependency direction Dependencies point toward narrow interfaces and stable lower-level concepts: @@ -246,8 +269,9 @@ and Effect Atom registry. memoize by hand), ad hoc Atom runtimes, direct Effect runtime execution, native `Date`, global `fetch`, throws in Effect generators, wildcard Module exports, Resources that read an API ResourceSource without - `makePresentableResource`, Layout reads outside application composition and - Widget Configuration, Layout-named identifiers in Domain, Resource, service, and + `makePresentableResource`, literal absolute Widget paths outside the Route + Registry, Layout reads outside application composition and Widget + Configuration, Layout-named identifiers in Domain, Resource, service, and Feature `model/` and `state/` code, and Layout-named files outside `ui/` and `app/layouts/`. - Architecture rules live in these tools, not in tests that read source text. diff --git a/packages/widget/src/App.tsx b/packages/widget/src/App.tsx index ac38bcb9e..77bb296a3 100644 --- a/packages/widget/src/App.tsx +++ b/packages/widget/src/App.tsx @@ -10,6 +10,7 @@ import { SKAtomRegistryProvider } from "./app/composition/providers/atom-runtime import { WidgetConfigurationUnavailable } from "./app/composition/widget-configuration-unavailable"; import { CompactRoutes } from "./app/layouts/compact/compact-routes"; import { DashboardRoutes } from "./app/layouts/dashboard/dashboard-routes"; +import { LayoutAppContainer } from "./app/layouts/layout-app-container"; import { applicationRoutes } from "./app/routes/application-routes"; import { useApplicationRouteEffects } from "./app/routes/react/use-application-route-effects"; import { applicationRouterAtom } from "./app/runtime/application-router"; @@ -22,7 +23,6 @@ import { useWidgetConfig, } from "./features/widget-configuration/index"; import { - AppContainer, NetworkNotSupported, NoEnabledYields, } from "./features/widget-shell/composition"; @@ -73,9 +73,9 @@ const SKAppRouter = () => { return ( }> - + - + ); }; diff --git a/packages/widget/src/app/composition/providers/mount-animation/index.tsx b/packages/widget/src/app/composition/providers/mount-animation/index.tsx index e2a5aece9..59e1c0666 100644 --- a/packages/widget/src/app/composition/providers/mount-animation/index.tsx +++ b/packages/widget/src/app/composition/providers/mount-animation/index.tsx @@ -1,22 +1,23 @@ import { useAtomMount } from "@effect/atom-react"; import { useEffect } from "react"; -import { useLocation } from "react-router"; import { mountAnimationCompletionAtom, useMountAnimation, } from "../../../../features/mount-animation/index"; +import { useWidgetRouteMatch } from "../../../../features/navigation/index"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; export const MountAnimationEffects = () => { - const location = useLocation(); + const onEarn = useWidgetRouteMatch(widgetRoutes.earn) !== null; const { dispatch } = useMountAnimation(); useAtomMount(mountAnimationCompletionAtom); useEffect(() => { - if (location.pathname !== "/") { + if (!onEarn) { dispatch({ type: "all" }); } - }, [location.pathname, dispatch]); + }, [onEarn, dispatch]); return null; }; diff --git a/packages/widget/src/app/composition/widget-configuration-unavailable.tsx b/packages/widget/src/app/composition/widget-configuration-unavailable.tsx index 53c90af2c..e9712187c 100644 --- a/packages/widget/src/app/composition/widget-configuration-unavailable.tsx +++ b/packages/widget/src/app/composition/widget-configuration-unavailable.tsx @@ -1,11 +1,9 @@ import { useState } from "react"; import { I18nextProvider } from "react-i18next"; -import { - AppContainer, - WidgetUnavailableWithoutConfiguration, -} from "../../features/widget-shell/composition"; +import { WidgetUnavailableWithoutConfiguration } from "../../features/widget-shell/composition"; import type { RejectedConfigurationPresentation } from "../../services/config/widget-config-boundary"; import { createWidgetI18nInstance } from "../../services/translation/widget-translation"; +import { LayoutAppContainer } from "../layouts/layout-app-container"; import { ThemeStyles } from "./providers/theme-wrapper"; /** @@ -25,9 +23,9 @@ export const WidgetConfigurationUnavailable = ({ return ( - + - + ); diff --git a/packages/widget/src/app/layouts/compact/compact-routes.tsx b/packages/widget/src/app/layouts/compact/compact-routes.tsx index c92d8d4e3..4f7698970 100644 --- a/packages/widget/src/app/layouts/compact/compact-routes.tsx +++ b/packages/widget/src/app/layouts/compact/compact-routes.tsx @@ -5,6 +5,7 @@ import { createActivityActionRoutes, } from "../../../features/activity/composition"; import { AnimatedEarnPage } from "../../../features/earn/composition"; +import { WidgetNavigate } from "../../../features/navigation/views"; import { AnimatedPositionsPage } from "../../../features/portfolio/composition"; import { usePortfolioPendingActionsCount } from "../../../features/portfolio/index"; import { CompactPositionDetailsPage } from "../../../features/position-details/composition"; @@ -24,12 +25,87 @@ import { } from "../../../features/widget-shell/views"; import { createYieldFlowRoutes } from "../../../features/yield-transaction-flow/composition"; import { getYieldFlowRouteGroup } from "../../../features/yield-transaction-flow/index"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; const CompactDetails = () => { const pendingActionsCount = usePortfolioPendingActionsCount(); return
; }; +/** The Compact Layout's route tree. */ +export const createCompactRoutes = () => { + const earnPath = widgetRoutes.earn.build(); + + return ( + }> + {/* Home + Tabs */} + }> + } /> + } + /> + + } /> + + } + > + {createActivityActionRoutes({ + selectsDefaultAction: false, + showsBackButton: true, + })} + + + + + }> + {/* Stake flow */} + {createYieldFlowRoutes({ mount: "Earn" })} + + {/* Position details hub + flow mounts */} + + } /> + } + /> + + {/* Legacy / accidental stake prefix → hub (no form) */} + + } /> + } /> + + + {/* Unstaking */} + + } /> + {createYieldFlowRoutes({ mount: "PositionExit" })} + } /> + + + {/* Pending Actions */} + + } /> + {createYieldFlowRoutes({ mount: "PositionManage" })} + } /> + + + } + /> + + + + } /> + + ); +}; + export const CompactRoutes = () => { const location = useLocation(); // Review, Steps, and Complete of one flow mount share a key, so the flow @@ -46,7 +122,7 @@ export const CompactRoutes = () => { if (yieldFlowRouteGroup !== null) { return `flow:${yieldFlowRouteGroup}`; } - if (detailsMatch) return "/"; + if (detailsMatch) return "tabs"; return location.key; }; const key = resolveRouteKey(); @@ -62,79 +138,7 @@ export const CompactRoutes = () => { - }> - {/* Home + Tabs */} - }> - } /> - } - /> - - } /> - - } - > - {createActivityActionRoutes({ - selectsDefaultAction: false, - showsBackButton: true, - })} - - - - - }> - {/* Stake flow */} - {createYieldFlowRoutes({ journey: "Enter" })} - - {/* Position details hub + flow mounts */} - - } /> - } - /> - - {/* Legacy / accidental stake prefix → hub (no form) */} - - } /> - } - /> - - - {/* Unstaking */} - - } /> - {createYieldFlowRoutes({ journey: "Exit" })} - } - /> - - - {/* Pending Actions */} - - } /> - {createYieldFlowRoutes({ journey: "Manage" })} - } - /> - - - } - /> - - - - } /> - + {createCompactRoutes()} diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts b/packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts new file mode 100644 index 000000000..9d86fca8f --- /dev/null +++ b/packages/widget/src/app/layouts/dashboard/dashboard-earn-route.ts @@ -0,0 +1,10 @@ +import { Schema } from "effect"; +import { YieldCategory } from "../../../domain/earn/contract"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; + +/** A category press only changes the page after its navigation is accepted. */ +export const dashboardEarnRoute = widgetRoutes.earn.withState( + Schema.Struct({ + yieldCategory: Schema.Literals(Object.values(YieldCategory)), + }) +); diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx index fa320519d..0521b9f73 100644 --- a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx +++ b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx @@ -1,5 +1,6 @@ +import { Option } from "effect"; import { AnimatePresence } from "motion/react"; -import { useRef, useState } from "react"; +import { useLayoutEffect, useRef, useState } from "react"; import { Navigate, Route, Routes, useLocation } from "react-router"; import type { YieldCategory } from "../../../domain/earn/contract"; import { @@ -13,6 +14,7 @@ import { import { createBorrowTransactionFlowRoutes } from "../../../features/borrow-transaction-flow/composition"; import { EarnPageContent } from "../../../features/earn/composition"; import { useSelectEarnCategory } from "../../../features/earn/index"; +import { useWidgetRouteState } from "../../../features/navigation/index"; import { ManagePage } from "../../../features/portfolio/composition"; import { DashboardPositionDetailsPage, @@ -22,7 +24,9 @@ import { WalletScopeRouteGuard } from "../../../features/wallet/composition"; import { GlobalModals } from "../../../features/widget-shell/composition"; // import { RewardsTabPage } from "../../../domain/portfolio/rewards"; import { createYieldFlowRoutes } from "../../../features/yield-transaction-flow/composition"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; import { BorrowFeatureRoute } from "../../routes/ui/borrow-feature-route"; +import { dashboardEarnRoute } from "./dashboard-earn-route"; import { DashboardEarnFlowOverview, DashboardEarnTab, @@ -37,141 +41,174 @@ const borrowRouteComposition = { }; export const shouldRegisterDashboardEarnFooterButton = (pathname: string) => - pathname === "/"; + Option.isSome(widgetRoutes.earn.match(pathname)); /** * Identity of the dashboard page a pathname renders. A change replays the page * transition (exit, then enter); steps inside one page, such as the Earn form * into its Review or a position into its unstake flow, keep the same key. */ -const resolveDashboardPageKey = (pathname: string) => { +const resolveDashboardPageKey = (pathname: string): string => { const tab = resolveDashboardRouteTab(pathname); if (tab !== "manage") return tab; - // `positions`, `positions/:integrationId/:balanceId`, or - // `positions/borrow/:marketId` - return pathname.split("/").filter(Boolean).slice(0, 3).join("/"); + // A position's pages share its key: a market position, a yield position, + // or the positions list. + const borrowPosition = widgetRoutes.borrowPosition.matchPrefix(pathname); + if (Option.isSome(borrowPosition)) { + return widgetRoutes.borrowPosition.build("Dashboard", borrowPosition.value); + } + const position = widgetRoutes.positionDetails.matchPrefix(pathname); + if (Option.isSome(position)) { + return widgetRoutes.positionDetails.build(position.value); + } + return widgetRoutes.positions.build(); +}; + +/** The Dashboard Layout's route tree. */ +export const createDashboardRoutes = ({ + registerEarnFooterButton, +}: { + readonly registerEarnFooterButton: boolean; +}) => { + const earnPath = widgetRoutes.earn.build(); + const positionsPath = widgetRoutes.positions.build(); + + return ( + <> + {/* Earn Tab */} + }> + }> + + } + /> + + + }> + {createYieldFlowRoutes({ + layout: , + mount: "Earn", + })} + + + + {/* Borrow Tab */} + }> + {createBorrowEntryRoutes(borrowRouteComposition)} + + + {/* Manage Tab + Position Details */} + + } /> + }> + {createBorrowMarketPositionRoutes(borrowRouteComposition)} + + }> + } + > + } /> + + + } /> + {createYieldFlowRoutes({ mount: "PositionStake" })} + } /> + + + } + /> + + + } /> + {createYieldFlowRoutes({ mount: "PositionExit" })} + } /> + + + + } /> + {createYieldFlowRoutes({ mount: "PositionManage" })} + } /> + + + } + /> + + + + + }> + {createActivityActionRoutes({ + ActionScopeGuard: WalletScopeRouteGuard, + selectsDefaultAction: true, + showsBackButton: false, + })} + + + ); }; export const DashboardRoutes = () => { const location = useLocation(); const selectCategory = useSelectEarnCategory(); - const [categoryPresses, setCategoryPresses] = useState(0); - // A pressed category is applied once the leaving page finished its exit, so - // it does not render the new category while fading out. The state drives - // the tab highlight; the ref is what the exit callback reads, because - // AnimatePresence keeps the callback from the render the exit started in. - const [pendingYieldCategory, setPendingYieldCategory] = - useState(null); + const earnNavigation = useWidgetRouteState(dashboardEarnRoute); + const isEarnPage = Option.isSome(widgetRoutes.earn.match(location.pathname)); + const acceptedCategory = isEarnPage + ? (earnNavigation?.yieldCategory ?? null) + : null; + const [categoryTransition, setCategoryTransition] = useState<{ + readonly key: string | null; + readonly category: YieldCategory | null; + }>({ key: null, category: null }); + + // A blocked navigation never changes location, so it cannot remount Steps. + // Keep the accepted category's key through Review, Steps, and Complete. + if (acceptedCategory !== null && categoryTransition.key !== location.key) { + setCategoryTransition({ key: location.key, category: acceptedCategory }); + } else if ( + acceptedCategory === null && + categoryTransition.category !== null + ) { + setCategoryTransition({ ...categoryTransition, category: null }); + } + + // AnimatePresence retains the callback from when the exit began. Read the + // latest accepted category, including another press during that exit. const pendingYieldCategoryRef = useRef(null); + useLayoutEffect(() => { + pendingYieldCategoryRef.current = categoryTransition.category; + }, [categoryTransition]); + const registerEarnFooterButton = shouldRegisterDashboardEarnFooterButton( location.pathname ); const pageKey = resolveDashboardPageKey(location.pathname); - // Category tab presses replay the Earn page transition like route tabs do. - const routesKey = pageKey === "earn" ? `earn:${categoryPresses}` : pageKey; - - const onYieldCategorySelect = (category: YieldCategory) => { - pendingYieldCategoryRef.current = category; - setPendingYieldCategory(category); - setCategoryPresses((n) => n + 1); - }; + const routesKey = + pageKey === "earn" ? `earn:${categoryTransition.key}` : pageKey; const onExitComplete = () => { const category = pendingYieldCategoryRef.current; if (category === null) return; pendingYieldCategoryRef.current = null; selectCategory(category); - setPendingYieldCategory(null); + setCategoryTransition((current) => ({ ...current, category: null })); }; return ( <> - + {/* The exiting Routes keeps its location, so it renders the page being left. */} - {/* Earn Tab */} - }> - }> - - } - /> - - - }> - {createYieldFlowRoutes({ - journey: "Enter", - layout: , - })} - - - - {/* Borrow Tab */} - }> - {createBorrowEntryRoutes(borrowRouteComposition)} - - - {/* Manage Tab + Position Details */} - - } /> - }> - {createBorrowMarketPositionRoutes(borrowRouteComposition)} - - } - > - } - > - } /> - - - } /> - {createYieldFlowRoutes({ journey: "Enter" })} - } /> - - - } - /> - - - } /> - {createYieldFlowRoutes({ journey: "Exit" })} - } /> - - - - } /> - {createYieldFlowRoutes({ journey: "Manage" })} - } /> - - - } - /> - - - - - }> - {createActivityActionRoutes({ - ActionScopeGuard: WalletScopeRouteGuard, - selectsDefaultAction: true, - showsBackButton: false, - })} - + {createDashboardRoutes({ registerEarnFooterButton })} diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx index 5bd1de774..55c3ff7a7 100644 --- a/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx +++ b/packages/widget/src/app/layouts/dashboard/dashboard-shell.tsx @@ -15,11 +15,9 @@ import { DashboardTabs } from "./dashboard-tabs"; export const DashboardShell = ({ children, - onYieldCategorySelect, pendingYieldCategory, }: { readonly children: ReactNode; - readonly onYieldCategorySelect: (category: YieldCategory) => void; readonly pendingYieldCategory: YieldCategory | null; }) => { const variant = useWidgetConfig("variant"); @@ -29,10 +27,7 @@ export const DashboardShell = ({ className={combineRecipeWithVariant({ rec: dashboardWrapper, variant })} > - + - Match.value(pathname).pipe( - Match.when(startsWith("/activity"), () => "activity" as const), - Match.when(startsWith("/borrow"), () => "borrow" as const), - Match.when(startsWith("/positions"), () => "manage" as const), - Match.orElse(() => "earn" as const) - ); +export const resolveDashboardRouteTab = (pathname: string): RouteTab => { + if (Option.isSome(widgetRoutes.activity.matchPrefix(pathname))) { + return "activity"; + } + if (Option.isSome(widgetRoutes.borrow.matchPrefix(pathname))) { + return "borrow"; + } + if (Option.isSome(widgetRoutes.positions.matchPrefix(pathname))) { + return "manage"; + } + return "earn"; +}; -const tabsMap: Record = { - activity: "/activity", - borrow: "/borrow", - earn: "/", - manage: "/positions", +const tabsMap: Record = { + activity: widgetRoutes.activity.build(), + borrow: widgetRoutes.borrow.build("Dashboard"), + earn: widgetRoutes.earn.build(), + manage: widgetRoutes.positions.build(), }; export const DashboardTabs = ({ - onYieldCategorySelect, pendingYieldCategory, }: { - /** Selects a yield category; applying it may wait for the page transition. */ - readonly onYieldCategorySelect: (category: YieldCategory) => void; /** A pressed yield category not yet applied to the Earn selection. */ readonly pendingYieldCategory: YieldCategory | null; }) => { const trackEvent = useTrackEvent(); - const navigate = useNavigate(); + const navigation = useWidgetNavigate(); const { view: yieldSelection } = useEarnYieldSelection(); const availableYieldCategories = yieldSelection.availableCategories; const selectedYieldCategory = pendingYieldCategory ?? yieldSelection.selectedCategory; - const location = useLocation(); - - const selectedTab = resolveDashboardRouteTab(location.pathname); + const selectedTab = resolveDashboardRouteTab(useLocation().pathname); const onRouteTabPress = (selected: RouteTab) => { if (selectedTab === selected) return; trackEvent("tabClicked", { selected }); - navigate(tabsMap[selected]); + navigation.push(tabsMap[selected]); }; const onYieldCategoryPress = (category: YieldCategory) => { @@ -65,8 +70,7 @@ export const DashboardTabs = ({ } trackEvent("tabClicked", { selected: category }); - onYieldCategorySelect(category); - navigate("/"); + navigation.push(dashboardEarnRoute.build({ yieldCategory: category })); }; const borrowEnabled = useWidgetConfig("borrowEnabled"); diff --git a/packages/widget/src/app/layouts/layout-app-container.tsx b/packages/widget/src/app/layouts/layout-app-container.tsx new file mode 100644 index 000000000..e3ff079b5 --- /dev/null +++ b/packages/widget/src/app/layouts/layout-app-container.tsx @@ -0,0 +1,62 @@ +import type { PropsWithChildren } from "react"; +import { + AppContainer, + type PageTransition, +} from "../../features/widget-shell/composition"; +import type { WidgetLayout } from "../../services/config/widget-config-model"; +import type { LayoutAttributeValue } from "../../shared/styles/theme/ids"; + +const compactPageTransition = { + initial: { opacity: 0, scale: 1, translateY: "-15px" }, + animate: { + opacity: 1, + translateY: 0, + transition: { duration: 0.5, delay: 0.1 }, + }, + exit: { + opacity: 0, + filter: "blur(8px)", + scale: 0.8, + transition: { duration: 0.4 }, + }, +} satisfies PageTransition; + +const dashboardPageTransition = { + initial: { opacity: 0 }, + animate: { opacity: 1, transition: { duration: 0.4, delay: 0.05 } }, + exit: { opacity: 0, filter: "blur(8px)", transition: { duration: 0.1 } }, +} satisfies PageTransition; + +/** + * How the shell presents each Layout: the Layout attribute that scopes + * Layout-specific styles, and the page enter and exit animation. + */ +const layoutPresentation: Readonly< + Record< + WidgetLayout, + { + readonly attribute: LayoutAttributeValue; + readonly pageTransition: PageTransition; + } + > +> = { + Compact: { attribute: "compact", pageTransition: compactPageTransition }, + Dashboard: { + attribute: "dashboard", + pageTransition: dashboardPageTransition, + }, +}; + +/** The shell's app container presented for the given Layout. */ +export const LayoutAppContainer = ({ + layout, + children, +}: PropsWithChildren<{ readonly layout: WidgetLayout }>) => { + const { attribute, pageTransition } = layoutPresentation[layout]; + + return ( + + {children} + + ); +}; diff --git a/packages/widget/src/app/routes/model/initial-route.ts b/packages/widget/src/app/routes/model/initial-route.ts index 14fdf29a5..365ff77bb 100644 --- a/packages/widget/src/app/routes/model/initial-route.ts +++ b/packages/widget/src/app/routes/model/initial-route.ts @@ -1,5 +1,8 @@ -import { Match } from "effect"; import type { WidgetLayout } from "../../../services/config/widget-config-model"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../services/navigation/widget-routes"; import type { InitTab } from "../../../services/wallet/init-params"; /** @@ -14,16 +17,13 @@ export const resolveInitialRoutePath = ({ readonly borrowAvailable: boolean; readonly layout: WidgetLayout; readonly tab: InitTab; -}): string => - Match.value({ borrowAvailable, layout, tab }).pipe( - Match.when({ tab: "activity" }, () => "/activity"), - Match.when( - ({ tab }) => tab === "positions" || tab === "manage", - () => "/positions" - ), - Match.when( - { borrowAvailable: true, layout: "Dashboard", tab: "borrow" }, - () => "/borrow" - ), - Match.orElse(() => "/") - ); +}): WidgetPath => { + if (tab === "activity") return widgetRoutes.activity.build(); + if (tab === "positions" || tab === "manage") { + return widgetRoutes.positions.build(); + } + if (tab === "borrow" && borrowAvailable && layout === "Dashboard") { + return widgetRoutes.borrow.build(layout); + } + return widgetRoutes.earn.build(); +}; diff --git a/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts b/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts index f35815a8a..13119fbe8 100644 --- a/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts +++ b/packages/widget/src/app/routes/model/tab-resources-prefetch-gate.ts @@ -1,3 +1,6 @@ +import { Option } from "effect"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; + export type TabResourcesPrefetchLandingTab = | "activity" | "borrow" @@ -8,10 +11,14 @@ export type TabResourcesPrefetchLandingTab = export const resolveTabResourcesPrefetchLanding = ( pathname: string ): TabResourcesPrefetchLandingTab => { - if (pathname.startsWith("/activity")) return "activity"; - if (pathname.startsWith("/borrow")) return "borrow"; - if (pathname.startsWith("/positions")) return "manage"; - if (pathname === "/") return "earn"; + if (Option.isSome(widgetRoutes.activity.matchPrefix(pathname))) { + return "activity"; + } + if (Option.isSome(widgetRoutes.borrow.matchPrefix(pathname))) return "borrow"; + if (Option.isSome(widgetRoutes.positions.matchPrefix(pathname))) { + return "manage"; + } + if (Option.isSome(widgetRoutes.earn.match(pathname))) return "earn"; return "other"; }; diff --git a/packages/widget/src/app/routes/state/tab-resources-prefetch.ts b/packages/widget/src/app/routes/state/tab-resources-prefetch.ts index e86d230b5..7c4e7c8ad 100644 --- a/packages/widget/src/app/routes/state/tab-resources-prefetch.ts +++ b/packages/widget/src/app/routes/state/tab-resources-prefetch.ts @@ -4,7 +4,7 @@ import { borrowLandingPrimaryReadyAtom } from "../../../features/borrow/index"; import { earnLandingPrimaryReadyAtom } from "../../../features/earn/index"; import { manageTabResourcesPrefetchAtom } from "../../../features/portfolio/index"; import { walletScopeAtom } from "../../../features/wallet/index"; -import { applicationRouterPathnameAtom } from "../../runtime/application-router"; +import { applicationRouterLocationAtom } from "../../runtime/application-router"; import { resolveTabResourcesPrefetchLanding, shouldWarmTabResources, @@ -12,7 +12,7 @@ import { export const tabResourcesPrefetchAtom = Atom.make((get) => { const tab = resolveTabResourcesPrefetchLanding( - get(applicationRouterPathnameAtom) + get(applicationRouterLocationAtom).pathname ); if ( diff --git a/packages/widget/src/app/routes/ui/borrow-feature-route.tsx b/packages/widget/src/app/routes/ui/borrow-feature-route.tsx index df2916241..179b05241 100644 --- a/packages/widget/src/app/routes/ui/borrow-feature-route.tsx +++ b/packages/widget/src/app/routes/ui/borrow-feature-route.tsx @@ -1,14 +1,16 @@ import type { ReactNode } from "react"; -import { Navigate, Outlet } from "react-router"; +import { Outlet } from "react-router"; +import { WidgetNavigate } from "../../../features/navigation/views"; import { useWidgetConfig } from "../../../features/widget-configuration/index"; +import type { WidgetPath } from "../../../services/navigation/widget-routes"; export const BorrowFeatureRoute = ({ fallbackPath, }: { - readonly fallbackPath: string; + readonly fallbackPath: WidgetPath; }): ReactNode => useWidgetConfig("borrowEnabled") ? ( ) : ( - + ); diff --git a/packages/widget/src/app/runtime/application-router.ts b/packages/widget/src/app/runtime/application-router.ts index d0b79bace..5c877dd5a 100644 --- a/packages/widget/src/app/runtime/application-router.ts +++ b/packages/widget/src/app/runtime/application-router.ts @@ -1,7 +1,11 @@ import { Effect, Option, Stream } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; -import { ApplicationRouter } from "../../services/navigation/application-router"; +import { + type ApplicationLocation, + ApplicationRouter, +} from "../../services/navigation/application-router"; +import { widgetRoutes } from "../../services/navigation/widget-routes"; import { applicationBaseRuntime } from "./application-base-runtime"; export const applicationRouterAtom = applicationBaseRuntime @@ -15,18 +19,20 @@ export const applicationRouterAtom = applicationBaseRuntime Atom.withLabel("applicationRouterAtom") ); -export const applicationRouterPathnameAtom = applicationBaseRuntime +export const applicationRouterLocationAtom = applicationBaseRuntime .atom( ApplicationRouter.use((applicationRouter) => - Effect.succeed(applicationRouter.pathnames) + Effect.succeed(applicationRouter.locations) ).pipe(Stream.unwrap) ) .pipe( Atom.map((result) => result.pipe( AsyncResult.value, - Option.getOrElse(() => "/") + Option.getOrElse( + (): ApplicationLocation => ({ pathname: widgetRoutes.earn.build() }) + ) ) ), - Atom.withLabel("applicationRouterPathnameAtom") + Atom.withLabel("applicationRouterLocationAtom") ); diff --git a/packages/widget/src/app/runtime/deep-link-coordinator.ts b/packages/widget/src/app/runtime/deep-link-coordinator.ts index afaed4818..622227d6c 100644 --- a/packages/widget/src/app/runtime/deep-link-coordinator.ts +++ b/packages/widget/src/app/runtime/deep-link-coordinator.ts @@ -1,16 +1,17 @@ import { Context, Effect, Layer, Ref, Stream } from "effect"; +import type { YieldPendingActionType } from "../../domain/action/pending-action"; +import type { YieldId } from "../../domain/identity/identifiers"; import { sameWalletScopeOwner, type WalletScopeKey, walletScopeOwnerKey, } from "../../domain/wallet/wallet-scope"; -import { getPositionDetailsHubPath } from "../../features/position-details/index"; import { makeStartYieldTransactionFlow } from "../../features/yield-transaction-flow/runtime"; import { - toWidgetPath, WidgetNavigation, type WidgetNavigationError, } from "../../services/navigation/widget-navigation"; +import { widgetRoutes } from "../../services/navigation/widget-routes"; import type { WalletRuntimeInvariantError } from "../../services/wallet/wallet-errors"; import { walletScopeFromState } from "../../services/wallet/wallet-scope-adapter"; import { WalletService } from "../../services/wallet/wallet-service"; @@ -33,9 +34,9 @@ export type PendingActionDeepLinkObservation = readonly _tag: "OpenValidatorSelection"; readonly balanceId: string; readonly intent: PendingActionIntent; - readonly pendingActionType: string; + readonly pendingActionType: YieldPendingActionType; readonly walletScope: WalletScopeKey; - readonly yieldId: string; + readonly yieldId: YieldId; }> | Readonly<{ readonly _tag: "StartYieldFlow"; @@ -49,7 +50,7 @@ export type DeepLinkRouteObservation = Readonly<{ readonly pendingAction: PendingActionDeepLinkObservation | null; readonly position: { readonly balanceId: string; - readonly yieldId: string; + readonly yieldId: YieldId; } | null; readonly ready: boolean; }>; @@ -163,13 +164,16 @@ export class DeepLinkCoordinator extends Context.Service< const openPendingAction = ( pendingAction: PendingActionDeepLinkObservation ) => { + // Both Layouts mount position details and its validator selection. if (pendingAction._tag === "OpenValidatorSelection") { return navigation .execute({ _tag: "Push", - path: toWidgetPath( - `/positions/${pendingAction.yieldId}/${pendingAction.balanceId}/select-validator/${pendingAction.pendingActionType}` - ), + to: widgetRoutes.positionSelectValidator.build({ + balanceId: pendingAction.balanceId, + integrationId: pendingAction.yieldId, + pendingActionType: pendingAction.pendingActionType, + }), }) .pipe(Effect.as(true)); } @@ -191,12 +195,10 @@ export class DeepLinkCoordinator extends Context.Service< navigation .execute({ _tag: "Push", - path: toWidgetPath( - getPositionDetailsHubPath({ - balanceId: position.balanceId, - integrationId: position.yieldId, - }) - ), + to: widgetRoutes.positionDetails.build({ + balanceId: position.balanceId, + integrationId: position.yieldId, + }), }) .pipe(Effect.as(true)) ); diff --git a/packages/widget/src/domain/action/pending-action.ts b/packages/widget/src/domain/action/pending-action.ts index 246747693..8b97d0568 100644 --- a/packages/widget/src/domain/action/pending-action.ts +++ b/packages/widget/src/domain/action/pending-action.ts @@ -1,10 +1,13 @@ import type BigNumber from "bignumber.js"; +import { Schema } from "effect"; import type { YieldBalanceType } from "../portfolio/positions"; -import type { ManageActionCommand, PendingAction } from "./models"; +import { ManageActionCommand, PendingAction } from "./models"; -export type YieldPendingActionType = - | PendingAction["type"] - | NonNullable; +export const YieldPendingActionType = Schema.Union([ + PendingAction.fields.type, + ManageActionCommand.fields.action, +]); +export type YieldPendingActionType = typeof YieldPendingActionType.Type; /** * Pending actions a position never needs: cancelling a queued request, or diff --git a/packages/widget/src/features/activity/model/activity-action.ts b/packages/widget/src/features/activity/model/activity-action.ts index 7cc99bf7f..1e6796177 100644 --- a/packages/widget/src/features/activity/model/activity-action.ts +++ b/packages/widget/src/features/activity/model/activity-action.ts @@ -11,6 +11,10 @@ import type { } from "../../../domain/earn/models"; import type { ActionId } from "../../../domain/identity/identifiers"; import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../services/navigation/widget-routes"; export type YieldActivityItem = { readonly _tag: "yield"; @@ -53,7 +57,9 @@ export const isActivitySelectionTarget = ( item._tag === target.source && item.actionData.id === target.actionId; -export const getActivityActionPath = (target: ActivitySelectionTarget) => +export const getActivityActionPath = ( + target: ActivitySelectionTarget +): WidgetPath => target.source === "borrow" - ? `/activity/borrow/${encodeURIComponent(target.actionId)}` - : `/activity/${encodeURIComponent(target.actionId)}`; + ? widgetRoutes.activityBorrowAction.build({ actionId: target.actionId }) + : widgetRoutes.activityAction.build({ actionId: target.actionId }); diff --git a/packages/widget/src/features/activity/react/activity-action-route.tsx b/packages/widget/src/features/activity/react/activity-action-route.tsx index 6fc644b83..16a48668b 100644 --- a/packages/widget/src/features/activity/react/activity-action-route.tsx +++ b/packages/widget/src/features/activity/react/activity-action-route.tsx @@ -1,7 +1,16 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { createContext, useContext, useState } from "react"; -import { Navigate, Outlet, useMatch, useParams } from "react-router"; +import { Outlet } from "react-router"; +import { + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../../services/navigation/widget-routes"; import { ContentLoaderSquare } from "../../../shared/ui/primitives/content-loader"; +import { + useWidgetRouteMatch, + useWidgetRoutePrefixMatch, +} from "../../navigation/index"; +import { WidgetNavigate } from "../../navigation/views"; import { walletScopeAtom } from "../../wallet/index"; import type { YieldSummaryProvider } from "../../yield-summary/index"; import { YieldActionContinuationSessionRoute } from "../../yield-transaction-flow/views"; @@ -48,9 +57,19 @@ export const ActivityActionRoute = ({ readonly showsBackButton: boolean; readonly source: ActivitySelectionTarget["source"]; }) => { - const { actionId: actionIdParam } = useParams(); - const stepsMatch = useMatch("/activity/:actionId/steps"); - const completeMatch = useMatch("/activity/:actionId/complete"); + const borrowActionId = useWidgetRoutePrefixMatch( + widgetRoutes.activityBorrowAction + )?.actionId; + const yieldActionId = useWidgetRoutePrefixMatch( + widgetRoutes.activityAction + )?.actionId; + const actionIdParam = source === "borrow" ? borrowActionId : yieldActionId; + const stepsMatch = useWidgetRouteMatch( + widgetYieldFlowRoutes.YieldActionContinuation.steps + ); + const completeMatch = useWidgetRouteMatch( + widgetYieldFlowRoutes.YieldActionContinuation.complete + ); const executionMatch = stepsMatch ?? completeMatch; const scope = useAtomValue(walletScopeAtom); const parsed = parseActivityRouteIntent({ @@ -61,7 +80,7 @@ export const ActivityActionRoute = ({ if (!scope) return ; if (parsed.status === "missing" || parsed.status === "invalid") { - return ; + return ; } return ( @@ -102,7 +121,7 @@ const BoundActivityActionRoute = ({ if (result.status === "unavailable") { return resolveUnavailableActivitySelection(selectionKey.intent) === "clear-route" ? ( - + ) : null; } @@ -112,7 +131,7 @@ const BoundActivityActionRoute = ({ // Only the explicit action route spans Review, Steps, and Complete, so it is // the one that owns a continuation. if (result.canContinue && selectionKey.intent._tag === "default") { - return ; + return ; } // A continuation started on this action's Review stays mounted through its // execution; any other action drops it. @@ -124,7 +143,7 @@ const BoundActivityActionRoute = ({ } const continuationReady = continuedActionId === actionId; if (selectionKey.surface === "execution" && !continuationReady) { - return ; + return ; } return ( diff --git a/packages/widget/src/features/activity/react/use-activity-page.ts b/packages/widget/src/features/activity/react/use-activity-page.ts index d75e09074..7ab8fc9c0 100644 --- a/packages/widget/src/features/activity/react/use-activity-page.ts +++ b/packages/widget/src/features/activity/react/use-activity-page.ts @@ -1,5 +1,9 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { useMatch, useNavigate, useParams } from "react-router"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; +import { + useWidgetNavigate, + useWidgetRoutePrefixMatch, +} from "../../navigation/index"; import { type ActivityActionItem, getActivityActionPath, @@ -21,15 +25,17 @@ export const useActivityPage = ({ }: { readonly allowDefaultSelection?: boolean; } = {}) => { - const navigate = useNavigate(); - const { actionId: actionIdParam } = useParams(); - const borrowMatch = useMatch("/activity/borrow/:actionId"); + const navigation = useWidgetNavigate(); + const borrowMatch = useWidgetRoutePrefixMatch( + widgetRoutes.activityBorrowAction + ); + const yieldMatch = useWidgetRoutePrefixMatch(widgetRoutes.activityAction); const view = useAtomValue(activityPageViewAtom); const loadMore = useAtomSet(loadMoreActivityAtom); const retry = useAtomSet(retryActivityPageAtom); const selectFilter = useAtomSet(setActivityPageFilterAtom); const parsed = parseActivityRouteIntent({ - actionIdParam, + actionIdParam: borrowMatch?.actionId ?? yieldMatch?.actionId, allowDefault: allowDefaultSelection, source: borrowMatch ? "borrow" : "yield", }); @@ -42,7 +48,7 @@ export const useActivityPage = ({ view, selectedAction, onActionSelect: (item: ActivityActionItem) => - navigate(getActivityActionPath(getActivitySelectionTarget(item))), + navigation.push(getActivityActionPath(getActivitySelectionTarget(item))), onFilterSelect: selectFilter, onLoadMore: () => loadMore(undefined), onRetry: () => retry(undefined), diff --git a/packages/widget/src/features/activity/routes.tsx b/packages/widget/src/features/activity/routes.tsx index b3bc7c9e1..c1a832ef6 100644 --- a/packages/widget/src/features/activity/routes.tsx +++ b/packages/widget/src/features/activity/routes.tsx @@ -1,5 +1,10 @@ import type { ComponentType } from "react"; import { Route } from "react-router"; +import { + type WidgetPath, + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../services/navigation/widget-routes"; import { YieldActionContinuationCompletePage, YieldActionContinuationExecution, @@ -8,13 +13,18 @@ import { import { ActivityActionRoute } from "./react/activity-action-route"; import { ActivityDetailsPage } from "./ui/activity-details/activity-details.page"; +const continuationRoutes = widgetYieldFlowRoutes.YieldActionContinuation; + const activityActionChildRoutes = ( <> } /> }> - } /> } + /> + } /> @@ -27,7 +37,7 @@ export const createActivityActionRoutes = ({ showsBackButton, }: { readonly ActionScopeGuard?: ComponentType<{ - readonly fallbackPath: string; + readonly fallbackPath: WidgetPath; }>; /** Whether the bare Activity route selects a default action. */ readonly selectsDefaultAction: boolean; @@ -43,10 +53,16 @@ export const createActivityActionRoutes = ({ ); const actionIdRoutes = ( <> - + } /> - + {activityActionChildRoutes} @@ -60,7 +76,11 @@ export const createActivityActionRoutes = ({ ) : null} {ActionScopeGuard ? ( - }> + + } + > {actionIdRoutes} ) : ( diff --git a/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx b/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx index a278831c8..b0fc5c61d 100644 --- a/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx +++ b/packages/widget/src/features/activity/ui/activity-details/activity-details.page.tsx @@ -3,8 +3,8 @@ import clsx from "clsx"; import { capitalize, toLowerCase } from "effect/String"; import { type ReactNode, useId } from "react"; import { Trans, useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; import { isEthenaUsdeStaking } from "../../../../domain/earn/yield"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { presentationClockAtom } from "../../../../shared/effect/presentation-clock"; import { borrowTokenToAppToken } from "../../../../shared/lib/borrow-token"; import { @@ -23,6 +23,7 @@ import { ClockClockWiseIcon } from "../../../../shared/ui/primitives/icons/clock import { ExternalLinkIcon } from "../../../../shared/ui/primitives/icons/external-link"; import { XIcon } from "../../../../shared/ui/primitives/icons/x-icon"; import { Text } from "../../../../shared/ui/primitives/typography/text"; +import { useWidgetNavigate } from "../../../navigation/index"; import { useTrackEvent, useTrackPage } from "../../../tracking/index"; import { PageCtaButton } from "../../../widget-shell/views"; import { @@ -317,7 +318,7 @@ const Receipt = ({ const { t } = useTranslation(); const trackEvent = useTrackEvent(); const { showsBackButton } = useActivityActionRoute(); - const navigate = useNavigate(); + const navigation = useWidgetNavigate(); const title = useActivityActionTitle(view.title); const network = capitalizeFirstLetters(view.network.replaceAll("-", " ")); @@ -333,7 +334,7 @@ const Receipt = ({ as="button" aria-label={t("activity.details.back")} className={styles.back} - onClick={() => navigate("/activity")} + onClick={() => navigation.push(widgetRoutes.activity.build())} > diff --git a/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx b/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx index 642c802df..4fb641439 100644 --- a/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx +++ b/packages/widget/src/features/activity/ui/dashboard/activity/index.tsx @@ -1,6 +1,8 @@ import { useAtomValue } from "@effect/atom-react"; -import { Outlet, useMatch } from "react-router"; +import { Outlet } from "react-router"; +import { widgetYieldFlowRoutes } from "../../../../../services/navigation/widget-routes"; import { Box } from "../../../../../shared/ui/primitives/box"; +import { useWidgetRouteMatch } from "../../../../navigation/index"; import { useSKWallet } from "../../../../wallet/index"; import { AnimationPage, @@ -13,8 +15,12 @@ import * as styles from "./styles.css"; export const ActivityTabPage = () => { const wallet = useSKWallet(); const view = useAtomValue(activityPageViewAtom); - const stepsMatch = useMatch("/activity/:actionId/steps"); - const completeMatch = useMatch("/activity/:actionId/complete"); + const stepsMatch = useWidgetRouteMatch( + widgetYieldFlowRoutes.YieldActionContinuation.steps + ); + const completeMatch = useWidgetRouteMatch( + widgetYieldFlowRoutes.YieldActionContinuation.complete + ); const isExecution = stepsMatch !== null || completeMatch !== null; const showSplit = wallet?.status === "connected" && hasActivityActions(view); diff --git a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts index f24c75c85..d1cf10c19 100644 --- a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts +++ b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts @@ -1,4 +1,4 @@ -import { Equal, Option, Ref, Schema } from "effect"; +import { Equal, Ref, Schema } from "effect"; import type { CollateralToken } from "../../../domain/borrow/catalog/collateral-token"; import type { Integration } from "../../../domain/borrow/catalog/integration"; import type { Market } from "../../../domain/borrow/catalog/market"; @@ -7,6 +7,7 @@ import type { ActionMetadata, } from "../../../domain/borrow/execution/action"; import type { ActionCommand } from "../../../domain/borrow/execution/action-command"; +import type { MarketId } from "../../../domain/borrow/ids"; import type { BorrowNetwork } from "../../../domain/borrow/network"; import type { RiskGateBlockReason, @@ -16,9 +17,11 @@ import type { import { exactDecimal } from "../../../domain/finance/exact"; import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope"; import { - toWidgetPath, - type WidgetPathInput, -} from "../../../services/navigation/widget-navigation"; + type WidgetFlowDestination, + type WidgetPath, + widgetBorrowFlowRoutes, + widgetRoutes, +} from "../../../services/navigation/widget-routes"; export type BorrowConstraintWarning = | "AmountExceedsAvailableLiquidity" @@ -361,7 +364,7 @@ export type BorrowEntryFlowDetails = { export type BorrowTransactionFlowEntry = | { readonly _tag: "BorrowEntry"; readonly details: BorrowEntryFlowDetails } - | { readonly _tag: "MarketPosition"; readonly marketId: string }; + | { readonly _tag: "MarketPosition"; readonly marketId: MarketId }; export type BorrowTransactionFlowIntake = BorrowTransactionFlowReview & { readonly entry: BorrowTransactionFlowEntry; @@ -404,30 +407,20 @@ const BorrowFlowNavigationState = Schema.Struct({ executionActionId: Schema.optional(Schema.String), }); -export const makeBorrowFlowNavigationState = ( - session: BorrowFlowSession, - executionActionId?: string -): typeof BorrowFlowNavigationState.Type => - executionActionId === undefined - ? { borrowFlowSession: session } - : { borrowFlowSession: session, executionActionId }; - -export const decodeBorrowFlowNavigationState = ( - state: unknown -): Option.Option => - Schema.decodeUnknownOption(BorrowFlowNavigationState)(state).pipe( - Option.map((decoded) => decoded.borrowFlowSession) - ); +type BorrowFlowNavigationState = typeof BorrowFlowNavigationState.Type; -export const decodeBorrowFlowExecutionActionId = ( - state: unknown -): string | null => - Schema.decodeUnknownOption(BorrowFlowNavigationState)(state).pipe( - Option.flatMap((decoded) => - Option.fromUndefinedOr(decoded.executionActionId) - ), - Option.getOrNull - ); +/** + * Each entry's Review, Steps, and Complete routes, carrying the Session (and + * on Steps and Complete, the handed-off action) in their navigation state. + */ +export const borrowFlowRoutes = { + BorrowEntry: widgetBorrowFlowRoutes.BorrowEntry.withState( + BorrowFlowNavigationState + ), + MarketPosition: widgetBorrowFlowRoutes.MarketPosition.withState( + BorrowFlowNavigationState + ), +} as const satisfies Record; export const getBorrowReviewTrackingProperties = ( intake: BorrowTransactionFlowIntake @@ -447,18 +440,24 @@ export const getBorrowReviewTrackingProperties = ( }; }; -export const getBorrowTransactionFlowRoutes = ( +/** + * The flow pages an entry mounts, and its base: the page the flow starts from + * and leaves to. Only the Dashboard Layout mounts Borrow. + */ +export const getBorrowTransactionFlowDestination = ( entry: BorrowTransactionFlowEntry -) => { - const basePath: WidgetPathInput = - entry._tag === "BorrowEntry" - ? "/borrow" - : `/positions/borrow/${entry.marketId}`; - - return { - basePath: toWidgetPath(basePath), - completePath: toWidgetPath(`${basePath}/complete`), - reviewPath: toWidgetPath(`${basePath}/review`), - stepsPath: toWidgetPath(`${basePath}/steps`), - } as const; -}; +): WidgetFlowDestination & + Readonly<{ readonly basePath: WidgetPath }> => + entry._tag === "BorrowEntry" + ? { + ...borrowFlowRoutes.BorrowEntry.at("Dashboard"), + basePath: widgetRoutes.borrow.build("Dashboard"), + } + : { + ...borrowFlowRoutes.MarketPosition.at("Dashboard", { + marketId: entry.marketId, + }), + basePath: widgetRoutes.borrowPosition.build("Dashboard", { + marketId: entry.marketId, + }), + }; diff --git a/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx b/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx index 1408778af..c581394fd 100644 --- a/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx +++ b/packages/widget/src/features/borrow-transaction-flow/react/borrow-flow-route.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { Option, Schema } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import { createContext, @@ -7,19 +6,21 @@ import { useContext, useState, } from "react"; -import { Navigate, Outlet, useLocation, useParams } from "react-router"; -import { - type MarketId, - MarketId as MarketIdSchema, -} from "../../../domain/borrow/ids"; +import { Outlet, useLocation } from "react-router"; +import type { MarketId } from "../../../domain/borrow/ids"; import { sameWalletScopeOwner } from "../../../domain/wallet/wallet-scope"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; +import { + useWidgetRoutePrefixMatch, + useWidgetRouteState, +} from "../../navigation/index"; +import { WidgetNavigate } from "../../navigation/views"; import { walletScopeAtom } from "../../wallet/index"; import { type BorrowFlowSession, type BorrowTransactionFlowEntry, - decodeBorrowFlowExecutionActionId, - decodeBorrowFlowNavigationState, - getBorrowTransactionFlowRoutes, + borrowFlowRoutes, + getBorrowTransactionFlowDestination, } from "../model/borrow-transaction-flow"; import { BorrowFlowExecutionKey, @@ -49,9 +50,18 @@ export const useBorrowTransactionFlowReview = () => ) ); +/** The action Steps and Complete were navigated to run, if any. */ +const useBorrowFlowExecutionActionId = () => { + const { intake } = useBorrowTransactionFlow(); + return ( + useWidgetRouteState(borrowFlowRoutes[intake.entry._tag]) + ?.executionActionId ?? null + ); +}; + /** The Execution of the action Steps and Complete were navigated to run. */ export const useBorrowTransactionFlowExecution = () => { - const actionId = decodeBorrowFlowExecutionActionId(useLocation().state); + const actionId = useBorrowFlowExecutionActionId(); const session = useBorrowTransactionFlow(); if (!actionId) throw new Error("Borrow Flow Execution is unavailable."); return useAtomValue( @@ -71,9 +81,13 @@ const getEntryFallbackPath = ( expected: BorrowTransactionFlowEntry["_tag"], marketId: MarketId | undefined ) => { - if (expected === "BorrowEntry") return "/borrow"; - if (marketId) return `/positions/borrow/${marketId}`; - return "/positions"; + if (expected === "BorrowEntry") { + return widgetRoutes.borrow.build("Dashboard"); + } + if (marketId) { + return widgetRoutes.borrowPosition.build("Dashboard", { marketId }); + } + return widgetRoutes.positions.build(); }; type MountedBorrowFlowSession = Readonly<{ @@ -92,14 +106,12 @@ export const BorrowTransactionFlowRoute = ({ }: { readonly expected: BorrowTransactionFlowEntry["_tag"]; }) => { - const routeParams = useParams(); - const marketId = routeParams.marketId - ? Schema.decodeSync(MarketIdSchema)(routeParams.marketId) - : undefined; + const marketId = useWidgetRoutePrefixMatch( + widgetRoutes.borrowPosition + )?.marketId; const location = useLocation(); - const navigated = Option.getOrNull( - decodeBorrowFlowNavigationState(location.state) - ); + const navigated = + useWidgetRouteState(borrowFlowRoutes[expected])?.borrowFlowSession ?? null; const [mounted, setMounted] = useState(null); if (navigated && navigated !== mounted?.session) { setMounted({ key: location.key, session: navigated }); @@ -109,7 +121,9 @@ export const BorrowTransactionFlowRoute = ({ !mounted || !matchesEntry(mounted.session.intake.entry, expected, marketId) ) { - return ; + return ( + + ); } return ; }; @@ -123,9 +137,9 @@ const BorrowFlowSessionRoute = ({ const walletScope = useAtomValue(walletScopeAtom); if (!walletScope || !sameWalletScopeOwner(walletScope, session.walletScope)) { return ( - ); } @@ -144,12 +158,12 @@ export const BorrowTransactionFlowExecutionRoute = ({ children, }: PropsWithChildren) => { const { intake } = useBorrowTransactionFlow(); - const actionId = decodeBorrowFlowExecutionActionId(useLocation().state); + const actionId = useBorrowFlowExecutionActionId(); if (!actionId) { return ( - ); } @@ -163,9 +177,9 @@ const ExecutionStarted = ({ children }: PropsWithChildren) => { ); if (workflow._tag === "Failure") { return ( - ); } @@ -178,19 +192,23 @@ export const BorrowTransactionFlowExecutionPage = ({ children, page, }: PropsWithChildren<{ readonly page: "Complete" | "Steps" }>) => { - const { intake } = useBorrowTransactionFlow(); - const location = useLocation(); + const session = useBorrowTransactionFlow(); + const actionId = useBorrowFlowExecutionActionId(); const state = useAtomValue(useBorrowTransactionFlowExecution().stateAtom); if (!AsyncResult.isSuccess(state)) return null; - const { completePath, stepsPath } = getBorrowTransactionFlowRoutes( - intake.entry - ); + const destination = getBorrowTransactionFlowDestination(session.intake.entry); + const navigationState = { + borrowFlowSession: session, + ...(actionId ? { executionActionId: actionId } : {}), + }; const completed = state.value._tag === "Completed"; if (page === "Steps" && completed) { - return ; + return ( + + ); } if (page === "Complete" && !completed) { - return ; + return ; } return children ?? ; }; diff --git a/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts b/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts index fe6715ca0..1e7399bfc 100644 --- a/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts +++ b/packages/widget/src/features/borrow-transaction-flow/state/borrow-flow-session.ts @@ -14,8 +14,7 @@ import { BorrowFlowSession, type BorrowTransactionFlowIntake, getBorrowReviewTrackingProperties, - getBorrowTransactionFlowRoutes, - makeBorrowFlowNavigationState, + getBorrowTransactionFlowDestination, } from "../model/borrow-transaction-flow"; /** Validates the intake and navigates to Review carrying a new Session. */ @@ -48,8 +47,9 @@ export const startBorrowTransactionFlow = Effect.fn( yield* Effect.uninterruptible( navigation.execute({ _tag: "Push", - path: getBorrowTransactionFlowRoutes(session.intake.entry).reviewPath, - state: makeBorrowFlowNavigationState(session), + to: getBorrowTransactionFlowDestination(session.intake.entry).review({ + borrowFlowSession: session, + }), }) ); const trackingProperties = getBorrowReviewTrackingProperties(session.intake); @@ -75,7 +75,7 @@ export const leaveBorrowFlow = (session: BorrowFlowSession) => Effect.uninterruptible( navigation.execute({ _tag: "Replace", - path: getBorrowTransactionFlowRoutes(session.intake.entry).basePath, + to: getBorrowTransactionFlowDestination(session.intake.entry).basePath, }) ) ); diff --git a/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts b/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts index a6ff60560..f51613f1a 100644 --- a/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts +++ b/packages/widget/src/features/borrow-transaction-flow/state/review-page.ts @@ -14,8 +14,7 @@ import { } from "../../../services/navigation/widget-navigation"; import { type BorrowFlowSession, - getBorrowTransactionFlowRoutes, - makeBorrowFlowNavigationState, + getBorrowTransactionFlowDestination, } from "../model/borrow-transaction-flow"; import { leaveBorrowFlow } from "./borrow-flow-session"; @@ -121,7 +120,7 @@ export const confirmBorrowAction = Effect.fn("confirmBorrowAction")(function* ( } const navigation = yield* WidgetNavigation; - const { stepsPath } = getBorrowTransactionFlowRoutes(session.intake.entry); + const destination = getBorrowTransactionFlowDestination(session.intake.entry); return yield* Effect.uninterruptible( Effect.gen(function* () { const held = yield* Ref.getAndUpdate( @@ -133,8 +132,10 @@ export const confirmBorrowAction = Effect.fn("confirmBorrowAction")(function* ( yield* navigation .execute({ _tag: "Push", - path: stepsPath, - state: makeBorrowFlowNavigationState(session, action.id), + to: destination.steps({ + borrowFlowSession: session, + executionActionId: action.id, + }), }) .pipe( Effect.tapError(() => diff --git a/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx b/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx index 74f3e614e..61b2e1569 100644 --- a/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx +++ b/packages/widget/src/features/borrow-transaction-flow/ui/borrow-flow-routes.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from "react"; import { Route } from "react-router"; +import { widgetBorrowFlowRoutes } from "../../../services/navigation/widget-routes"; import type { BorrowTransactionFlowEntry } from "../model/borrow-transaction-flow"; import { BorrowTransactionFlowExecutionPage, @@ -19,35 +20,42 @@ type BorrowTransactionFlowRouteMount = Readonly<{ readonly layout?: ReactElement; }>; -const flowPages = ( - <> - } /> - }> - - - - } - /> - - - - } - /> - - -); +const flowPages = (entry: BorrowTransactionFlowEntry["_tag"]) => { + const routes = widgetBorrowFlowRoutes[entry]; + return ( + <> + } /> + }> + + + + } + /> + + + + } + /> + + + ); +}; export const createBorrowTransactionFlowRoutes = ({ entry, layout, }: BorrowTransactionFlowRouteMount) => ( }> - {layout ? {flowPages} : flowPages} + {layout ? ( + {flowPages(entry)} + ) : ( + flowPages(entry) + )} ); diff --git a/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx b/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx index d2d9912bb..92f47a482 100644 --- a/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx +++ b/packages/widget/src/features/borrow-transaction-flow/ui/steps.tsx @@ -17,7 +17,7 @@ import { PageContainer, PageCtaButton, } from "../../widget-shell/views"; -import { getBorrowTransactionFlowRoutes } from "../model/borrow-transaction-flow"; +import { getBorrowTransactionFlowDestination } from "../model/borrow-transaction-flow"; import { useBorrowTransactionFlow, useBorrowTransactionFlowExecution, @@ -34,7 +34,7 @@ export const BorrowStepsPage = () => { const executionFlow = useBorrowTransactionFlowExecution(); const back = useAtomSet(executionFlow.leaveAtom); const leaveConfirmation = useExecutionLeaveConfirmation( - getBorrowTransactionFlowRoutes(flow.intake.entry).completePath + getBorrowTransactionFlowDestination(flow.intake.entry).completePath ); const leave = () => leaveConfirmation.confirm(() => back(undefined)); const execution = useBorrowExecution(); diff --git a/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx b/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx index b54bf3f6a..6a01eb3df 100644 --- a/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx +++ b/packages/widget/src/features/borrow/borrow-entry/ui/routes.tsx @@ -1,11 +1,16 @@ import type { ComponentType, ReactElement, ReactNode } from "react"; -import { Navigate, Route } from "react-router"; +import { Route } from "react-router"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../../services/navigation/widget-routes"; +import { WidgetNavigate } from "../../../navigation/views"; import { BorrowEntryLayout, BorrowFlowLayout, BorrowShell } from "./layout"; import { BorrowFormPage } from "./page"; type BorrowEntryRouteComposition = Readonly<{ BorrowConnectedWalletRoute: ComponentType; - WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: string }>; + WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: WidgetPath }>; createBorrowTransactionFlowRoutes: (input: { readonly entry: "BorrowEntry"; readonly layout: ReactElement; @@ -16,21 +21,27 @@ export const createBorrowEntryRoutes = ({ BorrowConnectedWalletRoute, WalletScopeRouteGuard, createBorrowTransactionFlowRoutes, -}: BorrowEntryRouteComposition) => ( - <> - }> - }> - } /> - - }> - }> - {createBorrowTransactionFlowRoutes({ - entry: "BorrowEntry", - layout: , - })} +}: BorrowEntryRouteComposition) => { + const borrowPath = widgetRoutes.borrow.build("Dashboard"); + return ( + <> + }> + }> + } /> + + }> + }> + {createBorrowTransactionFlowRoutes({ + entry: "BorrowEntry", + layout: , + })} + - - } /> - -); + } + /> + + ); +}; diff --git a/packages/widget/src/features/borrow/composition.ts b/packages/widget/src/features/borrow/composition.ts index 7478f634c..4ad7075a8 100644 --- a/packages/widget/src/features/borrow/composition.ts +++ b/packages/widget/src/features/borrow/composition.ts @@ -1,10 +1,11 @@ import type { ComponentType, ReactElement, ReactNode } from "react"; +import type { WidgetPath } from "../../services/navigation/widget-routes"; import { createBorrowEntryRoutes as createEntryRoutes } from "./borrow-entry/composition"; import { createBorrowMarketPositionRoutes as createMarketPositionRoutes } from "./market-position/composition"; import { BorrowConnectedWalletRoute } from "./wallet/composition"; type BorrowRouteComposition = Readonly<{ - WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: string }>; + WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: WidgetPath }>; createBorrowTransactionFlowRoutes: (input: { readonly entry: "BorrowEntry" | "MarketPosition"; readonly layout?: ReactElement; diff --git a/packages/widget/src/features/borrow/market-position/ui/action.page.tsx b/packages/widget/src/features/borrow/market-position/ui/action.page.tsx index 90b5d1eec..f705dc577 100644 --- a/packages/widget/src/features/borrow/market-position/ui/action.page.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/action.page.tsx @@ -1,14 +1,16 @@ import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { Navigate, useParams } from "react-router"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { Box } from "../../../../shared/ui/primitives/box"; import { Text } from "../../../../shared/ui/primitives/typography/text"; +import { useWidgetRouteMatch } from "../../../navigation/index"; +import { WidgetNavigate } from "../../../navigation/views"; import { type BorrowPositionAction, isBorrowCollateralToggleAction, } from "../model/details"; import { BorrowPositionBreadcrumb } from "./components/breadcrumb"; -import { getBorrowPositionBasePath, useBorrowPositionContext } from "./context"; +import { useBorrowPositionContext } from "./context"; import { RepayActionForm } from "./forms/repay-action-form"; import { WithdrawActionForm } from "./forms/withdraw-action-form"; @@ -31,7 +33,13 @@ const BorrowPositionActionForm = ({ }; export const BorrowPositionActionPage = () => { - const { actionId, marketId } = useParams(); + const match = useWidgetRouteMatch(widgetRoutes.borrowPositionAction); + const actionId = match?.actionId; + const positionPath = match + ? widgetRoutes.borrowPosition.build("Dashboard", { + marketId: match.marketId, + }) + : widgetRoutes.positions.build(); const { t } = useTranslation(); const { actions, model, position } = useBorrowPositionContext(); const action = actions.find((candidate) => candidate.id === actionId); @@ -45,7 +53,7 @@ export const BorrowPositionActionPage = () => { } if (isBorrowCollateralToggleAction(action)) { - return ; + return ; } return ; @@ -55,7 +63,7 @@ export const BorrowPositionActionPage = () => { return ( <> diff --git a/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx b/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx index c870d4b3d..85c785261 100644 --- a/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/actions.page.tsx @@ -1,8 +1,12 @@ import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { Box } from "../../../../shared/ui/primitives/box"; import { Button } from "../../../../shared/ui/primitives/button"; import { Text } from "../../../../shared/ui/primitives/typography/text"; +import { + useWidgetNavigate, + useWidgetRouteMatch, +} from "../../../navigation/index"; import { type BorrowPositionAction, isBorrowCollateralToggleAction, @@ -13,7 +17,8 @@ import * as styles from "./styles.css"; export const BorrowPositionActionsPage = () => { const { t } = useTranslation(); - const navigate = useNavigate(); + const navigation = useWidgetNavigate(); + const marketId = useWidgetRouteMatch(widgetRoutes.borrowPosition)?.marketId; const { actions: positionActions, model, @@ -23,8 +28,15 @@ export const BorrowPositionActionsPage = () => { (action) => !isBorrowCollateralToggleAction(action) ); - const onActionSelect = (action: BorrowPositionAction) => - navigate(`action/${action.id}`); + const onActionSelect = (action: BorrowPositionAction) => { + if (!marketId) return; + navigation.push( + widgetRoutes.borrowPositionAction.build("Dashboard", { + actionId: action.id, + marketId, + }) + ); + }; return ( <> diff --git a/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx b/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx index 6daeccff4..439c28f88 100644 --- a/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/components/breadcrumb.tsx @@ -1,17 +1,21 @@ import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../../../services/navigation/widget-routes"; import { PositionDetailsBreadcrumb } from "../../../../../shared/ui/components/position-details"; +import { useWidgetNavigate } from "../../../../navigation/index"; import { BackButton, BackButtonProvider } from "../../../../widget-shell/views"; export const BorrowPositionBreadcrumb = ({ - backPath = "/positions", + backPath = widgetRoutes.positions.build(), positionName, }: { - readonly backPath?: string; + readonly backPath?: WidgetPath; readonly positionName: string | null; }) => { const { t } = useTranslation(); - const navigate = useNavigate(); + const navigation = useWidgetNavigate(); return ( @@ -19,7 +23,7 @@ export const BorrowPositionBreadcrumb = ({ backButton={ navigate(backPath)} + onClick={() => navigation.push(backPath)} /> } positionName={positionName} diff --git a/packages/widget/src/features/borrow/market-position/ui/context.ts b/packages/widget/src/features/borrow/market-position/ui/context.ts index 83922faf3..9651050cf 100644 --- a/packages/widget/src/features/borrow/market-position/ui/context.ts +++ b/packages/widget/src/features/borrow/market-position/ui/context.ts @@ -15,6 +15,3 @@ export type BorrowPositionContext = { export const useBorrowPositionContext = () => useOutletContext(); - -export const getBorrowPositionBasePath = (marketId: string | undefined) => - marketId ? `/positions/borrow/${marketId}` : "/positions"; diff --git a/packages/widget/src/features/borrow/market-position/ui/details.page.tsx b/packages/widget/src/features/borrow/market-position/ui/details.page.tsx index bf7ac985b..8abe2dc2f 100644 --- a/packages/widget/src/features/borrow/market-position/ui/details.page.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/details.page.tsx @@ -1,11 +1,17 @@ import { useAtomSet } from "@effect/atom-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Navigate, Outlet, useLocation, useParams } from "react-router"; +import { Outlet } from "react-router"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { toPresentable } from "../../../../shared/effect/async-result"; import { PositionDetailsPane } from "../../../../shared/ui/components/position-details"; import { Text } from "../../../../shared/ui/primitives/typography/text"; import { useWidgetPresentation } from "../../../../shared/ui/widget-presentation"; +import { + useWidgetRouteMatch, + useWidgetRoutePrefixMatch, +} from "../../../navigation/index"; +import { WidgetNavigate } from "../../../navigation/views"; import { useTrackPage } from "../../../tracking/index"; import { AnimationPage, @@ -28,16 +34,16 @@ import { BorrowPositionActionsSkeleton, BorrowPositionInfoSkeleton, } from "./components/skeletons"; -import { - type BorrowPositionContext, - getBorrowPositionBasePath, -} from "./context"; +import type { BorrowPositionContext } from "./context"; export const BorrowPositionDetailsPage = () => { useTrackPage("positionDetails"); - const { marketId } = useParams(); - const location = useLocation(); + const marketId = useWidgetRoutePrefixMatch( + widgetRoutes.borrowPosition + )?.marketId; + const hasNestedRoute = + useWidgetRouteMatch(widgetRoutes.borrowPosition) === null; const { t } = useTranslation(); const { locale } = useWidgetPresentation(); const startPositionReview = useAtomSet(startBorrowPositionActionReviewAtom); @@ -60,9 +66,6 @@ export const BorrowPositionDetailsPage = () => { }) : []; const isPositionLoading = presentablePosition._tag === "Loading"; - const positionBasePath = getBorrowPositionBasePath(marketId); - const normalizedPathname = location.pathname.replace(/\/+$/u, ""); - const hasNestedRoute = normalizedPathname !== positionBasePath; const shouldShowLeftPane = hasNestedRoute || actions.length > 0 || !!position; const context: BorrowPositionContext = { actions, @@ -99,7 +102,7 @@ export const BorrowPositionDetailsPage = () => { // A closed position (e.g. after a full repay) has nothing to manage; nested // flow routes still render so Complete can show the settled result. if (!hasNestedRoute && presentablePosition._tag === "Ready" && !position) { - return ; + return ; } const rightContent = (() => { diff --git a/packages/widget/src/features/borrow/market-position/ui/routes.tsx b/packages/widget/src/features/borrow/market-position/ui/routes.tsx index ee1ee806c..94891c37b 100644 --- a/packages/widget/src/features/borrow/market-position/ui/routes.tsx +++ b/packages/widget/src/features/borrow/market-position/ui/routes.tsx @@ -1,12 +1,17 @@ import type { ComponentType, ReactNode } from "react"; -import { Navigate, Route } from "react-router"; +import { Route } from "react-router"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../../services/navigation/widget-routes"; +import { WidgetNavigate } from "../../../navigation/views"; import { BorrowPositionActionPage } from "./action.page.tsx"; import { BorrowPositionActionsPage } from "./actions.page.tsx"; import { BorrowPositionDetailsPage } from "./details.page.tsx"; type BorrowMarketPositionRouteComposition = Readonly<{ BorrowConnectedWalletRoute: ComponentType; - WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: string }>; + WalletScopeRouteGuard: ComponentType<{ readonly fallbackPath: WidgetPath }>; createBorrowTransactionFlowRoutes: (input: { readonly entry: "MarketPosition"; }) => ReactNode; @@ -16,20 +21,29 @@ export const createBorrowMarketPositionRoutes = ({ BorrowConnectedWalletRoute, WalletScopeRouteGuard, createBorrowTransactionFlowRoutes, -}: BorrowMarketPositionRouteComposition) => ( - <> - }> - }> - }> - } /> +}: BorrowMarketPositionRouteComposition) => { + const positionsPath = widgetRoutes.positions.build(); + return ( + <> + }> + }> } - /> - {createBorrowTransactionFlowRoutes({ entry: "MarketPosition" })} + path={widgetRoutes.borrowPosition.pattern} + element={} + > + } /> + } + /> + {createBorrowTransactionFlowRoutes({ entry: "MarketPosition" })} + - - } /> - -); + } + /> + + ); +}; diff --git a/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx b/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx index 6e43d9fc8..0d7661cb7 100644 --- a/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx +++ b/packages/widget/src/features/borrow/wallet/ui/connected-wallet-route.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; -import { Navigate, Outlet } from "react-router"; +import { Outlet } from "react-router"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { ContentLoaderSquare } from "../../../../shared/ui/primitives/content-loader"; +import { WidgetNavigate } from "../../../navigation/views"; import { useBorrowWalletView } from "../react/use-borrow-wallet"; export const BorrowConnectedWalletRoute = (): ReactNode => { @@ -11,5 +13,5 @@ export const BorrowConnectedWalletRoute = (): ReactNode => { if (walletView.status === "connecting") { return ; } - return ; + return ; }; diff --git a/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts b/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts index 1fd0cc30c..b48c726fc 100644 --- a/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts +++ b/packages/widget/src/features/leave-confirmation/react/use-leave-confirmation.ts @@ -1,7 +1,10 @@ import { useAtomMount, useAtomValue } from "@effect/atom-react"; import { useRef, useState } from "react"; import { type Blocker, useBlocker } from "react-router"; -import type { WidgetPath } from "../../../services/navigation/widget-navigation"; +import { + isWidgetPath, + type WidgetPath, +} from "../../../services/navigation/widget-routes"; import { beforeUnloadLeaveGuardAtom, leaveGuardedAtom, @@ -73,7 +76,7 @@ export const useExecutionLeaveConfirmation = (completePath: WidgetPath) => { guarded && !leaving.current && nextLocation.pathname !== currentLocation.pathname && - nextLocation.pathname !== completePath + !isWidgetPath(nextLocation.pathname, completePath) ); // Each blocked attempt is a new Blocker; the same history entry is not. const heldNavigation = blocker.state === "blocked" ? blocker : null; diff --git a/packages/widget/src/features/navigation/index.ts b/packages/widget/src/features/navigation/index.ts new file mode 100644 index 000000000..325596b69 --- /dev/null +++ b/packages/widget/src/features/navigation/index.ts @@ -0,0 +1,6 @@ +export { + useWidgetNavigate, + useWidgetRouteMatch, + useWidgetRoutePrefixMatch, + useWidgetRouteState, +} from "./react/widget-navigation"; diff --git a/packages/widget/src/features/navigation/react/widget-navigation.ts b/packages/widget/src/features/navigation/react/widget-navigation.ts new file mode 100644 index 000000000..9c7fe4682 --- /dev/null +++ b/packages/widget/src/features/navigation/react/widget-navigation.ts @@ -0,0 +1,46 @@ +import { Option } from "effect"; +import { useLocation, useNavigate } from "react-router"; +import { + type WidgetNavigationTarget, + widgetTargetPath, + widgetTargetState, +} from "../../../services/navigation/widget-routes"; + +type RouteMatcher = Readonly<{ + readonly match: (pathname: string) => Option.Option; + readonly matchPrefix: (pathname: string) => Option.Option; +}>; + +type RouteState = Readonly<{ + readonly state: (locationState: unknown) => Option.Option; +}>; + +/** Navigates to registry paths and locations from React. */ +export const useWidgetNavigate = () => { + const navigate = useNavigate(); + return { + back: () => navigate(-1), + push: (to: WidgetNavigationTarget) => + navigate(widgetTargetPath(to), { state: widgetTargetState(to) }), + replace: (to: WidgetNavigationTarget) => + navigate(widgetTargetPath(to), { + replace: true, + state: widgetTargetState(to), + }), + } as const; +}; + +/** The route's params while the current location is exactly that route. */ +export const useWidgetRouteMatch = ( + route: RouteMatcher +): Params | null => Option.getOrNull(route.match(useLocation().pathname)); + +/** The route's params while the current location is that route or inside it. */ +export const useWidgetRoutePrefixMatch = ( + route: RouteMatcher +): Params | null => Option.getOrNull(route.matchPrefix(useLocation().pathname)); + +/** The current location's state, decoded as the route's bound state. */ +export const useWidgetRouteState = ( + route: RouteState +): State | null => Option.getOrNull(route.state(useLocation().state)); diff --git a/packages/widget/src/features/navigation/ui/widget-navigate.tsx b/packages/widget/src/features/navigation/ui/widget-navigate.tsx new file mode 100644 index 000000000..31d695240 --- /dev/null +++ b/packages/widget/src/features/navigation/ui/widget-navigate.tsx @@ -0,0 +1,35 @@ +import type { PropsWithChildren } from "react"; +import { type LinkProps, Navigate } from "react-router"; +import { + type WidgetNavigationTarget, + widgetTargetPath, + widgetTargetState, +} from "../../../services/navigation/widget-routes"; +import { SKLink } from "../../../shared/ui/primitives/link"; + +/** Redirects to a registry path or location while rendered. */ +export const WidgetNavigate = ({ + replace, + to, +}: { + readonly replace?: boolean; + readonly to: WidgetNavigationTarget; +}) => ( + +); + +/** A link to a registry path or location. */ +export const WidgetLink = ({ + to, + ...rest +}: PropsWithChildren< + Omit & { + readonly to: WidgetNavigationTarget; + } +>) => ( + +); diff --git a/packages/widget/src/features/navigation/views.ts b/packages/widget/src/features/navigation/views.ts new file mode 100644 index 000000000..11093bf4c --- /dev/null +++ b/packages/widget/src/features/navigation/views.ts @@ -0,0 +1 @@ +export { WidgetLink, WidgetNavigate } from "./ui/widget-navigate"; diff --git a/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx b/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx index 0fd50099f..795263681 100644 --- a/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx +++ b/packages/widget/src/features/portfolio/ui/components/earn-position-list-item.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { isConfidentialYield } from "../../../../domain/earn/confidential"; import type { EarnYieldWithProvider } from "../../../../domain/earn/models"; import type { PositionDetailsLabelType } from "../../../../domain/portfolio/positions"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { formatTokenAmount, formatUsd, @@ -12,11 +13,11 @@ import { TokenIcon } from "../../../../shared/ui/components/token-icon"; import { ToolTip } from "../../../../shared/ui/components/tooltip"; import { Box } from "../../../../shared/ui/primitives/box"; import { ContentLoaderSquare } from "../../../../shared/ui/primitives/content-loader"; -import { SKLink } from "../../../../shared/ui/primitives/link"; import { ListItem } from "../../../../shared/ui/primitives/list/list-item"; import { Spinner } from "../../../../shared/ui/primitives/spinner"; import { Text } from "../../../../shared/ui/primitives/typography/text"; import { useWidgetPresentation } from "../../../../shared/ui/widget-presentation"; +import { WidgetLink } from "../../../navigation/views"; import type { YieldSummaryProvider } from "../../../yield-summary/index"; import type { PositionItem } from "../../state/read-models/positions"; import { @@ -147,9 +148,11 @@ export const EarnPositionListItem = ({ })(); return ( - @@ -287,6 +290,6 @@ export const EarnPositionListItem = ({ )} - + ); }; diff --git a/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx b/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx index 0cc7c7e3e..a05d74589 100644 --- a/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx +++ b/packages/widget/src/features/portfolio/ui/dashboard/positions/components/positions-list-item.tsx @@ -1,11 +1,12 @@ import { useTranslation } from "react-i18next"; import type { MarketPosition } from "../../../../../../domain/borrow/positions/market-position"; +import { widgetRoutes } from "../../../../../../services/navigation/widget-routes"; import { TokenIcon } from "../../../../../../shared/ui/components/token-icon"; import { Box } from "../../../../../../shared/ui/primitives/box"; -import { SKLink } from "../../../../../../shared/ui/primitives/link"; import { ListItem } from "../../../../../../shared/ui/primitives/list/list-item"; import { Text } from "../../../../../../shared/ui/primitives/typography/text"; import { useWidgetPresentation } from "../../../../../../shared/ui/widget-presentation"; +import { WidgetLink } from "../../../../../navigation/views"; import type { UnifiedPositionItem } from "../../../../state/read-models/positions"; import { EarnPositionListItem } from "../../../components/earn-position-list-item"; import { @@ -29,7 +30,11 @@ const BorrowMarketPositionListItem = ({ const model = getBorrowMarketPositionListItemModel({ locale, position, t }); return ( - + - + ); }; diff --git a/packages/widget/src/features/position-details/index.ts b/packages/widget/src/features/position-details/index.ts deleted file mode 100644 index aadf9800b..000000000 --- a/packages/widget/src/features/position-details/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { getPositionDetailsHubPath } from "./model/hub"; diff --git a/packages/widget/src/features/position-details/model/hub.ts b/packages/widget/src/features/position-details/model/hub.ts index e8686c123..84635418e 100644 --- a/packages/widget/src/features/position-details/model/hub.ts +++ b/packages/widget/src/features/position-details/model/hub.ts @@ -5,14 +5,6 @@ export type PositionDetailsActionCapabilities = { readonly canUnstake: boolean; }; -export const getPositionDetailsHubPath = ({ - integrationId, - balanceId, -}: { - integrationId: string | undefined; - balanceId: string | undefined; -}): `/${string}` => `/positions/${integrationId}/${balanceId}`; - export const resolvePositionDetailsActionMode = ({ canStake, canUnstake, diff --git a/packages/widget/src/features/position-details/react/use-pending-action-select-validator-match.ts b/packages/widget/src/features/position-details/react/use-pending-action-select-validator-match.ts deleted file mode 100644 index 07d549b2e..000000000 --- a/packages/widget/src/features/position-details/react/use-pending-action-select-validator-match.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useMatch } from "react-router"; - -export const usePendingActionSelectValidatorMatch = () => - useMatch( - "positions/:integrationId/:balanceId/select-validator/:pendingActionType" - ); diff --git a/packages/widget/src/features/position-details/react/use-position-details-hub-match.ts b/packages/widget/src/features/position-details/react/use-position-details-hub-match.ts deleted file mode 100644 index bcc49bcad..000000000 --- a/packages/widget/src/features/position-details/react/use-position-details-hub-match.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { useMatch } from "react-router"; - -export const usePositionDetailsHubMatch = () => - useMatch("positions/:integrationId/:balanceId"); diff --git a/packages/widget/src/features/position-details/react/use-position-details-stake-match.ts b/packages/widget/src/features/position-details/react/use-position-details-stake-match.ts deleted file mode 100644 index 8442df33a..000000000 --- a/packages/widget/src/features/position-details/react/use-position-details-stake-match.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { useMatch } from "react-router"; -import { usePositionDetailsHubMatch } from "./use-position-details-hub-match"; - -export const usePositionDetailsStakeMatch = (path?: string) => { - const stakeFlowMatch = useMatch( - `positions/:integrationId/:balanceId/stake/${path ?? "*"}` - ); - const hubMatch = usePositionDetailsHubMatch(); - - if (path) return stakeFlowMatch; - - return hubMatch; -}; diff --git a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-match.ts b/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-match.ts deleted file mode 100644 index d38a709da..000000000 --- a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-match.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useMatch } from "react-router"; -import { usePositionDetailsHubMatch } from "./use-position-details-hub-match"; - -export const useUnstakeOrPendingActionMatch = (path?: string) => { - const specificMatch = useMatch( - `positions/:integrationId/:balanceId/${path ?? "*"}` - ); - const nestedMatch = useMatch("positions/:integrationId/:balanceId/*"); - const hubMatch = usePositionDetailsHubMatch(); - - if (path) return specificMatch; - - return nestedMatch ?? hubMatch; -}; diff --git a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts b/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts index 2eb7ab88f..e15064510 100644 --- a/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts +++ b/packages/widget/src/features/position-details/react/use-unstake-or-pending-action-params.ts @@ -1,25 +1,19 @@ -import { Option, Schema } from "effect"; -import type { YieldPendingActionType } from "../../../domain/action/pending-action"; -import { YieldId } from "../../../domain/identity/identifiers"; -import { usePendingActionSelectValidatorMatch } from "./use-pending-action-select-validator-match"; -import { useUnstakeOrPendingActionMatch } from "./use-unstake-or-pending-action-match"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; +import { + useWidgetRouteMatch, + useWidgetRoutePrefixMatch, +} from "../../navigation/index"; export const useUnstakeOrPendingActionParams = () => { - const unstakeOrPendingActionFlowMatch = useUnstakeOrPendingActionMatch(); - const pendingActionSelectValidatorMatch = - usePendingActionSelectValidatorMatch(); + const positionMatch = useWidgetRoutePrefixMatch(widgetRoutes.positionDetails); + const pendingActionSelectValidatorMatch = useWidgetRouteMatch( + widgetRoutes.positionSelectValidator + ); - const { balanceId, integrationId: rawIntegrationId } = - unstakeOrPendingActionFlowMatch?.params ?? - pendingActionSelectValidatorMatch?.params ?? - {}; - - const integrationId = rawIntegrationId - ? Schema.decodeOption(YieldId)(rawIntegrationId).pipe(Option.getOrUndefined) - : undefined; - - const pendingActionType = pendingActionSelectValidatorMatch?.params - .pendingActionType as YieldPendingActionType | undefined; + const balanceId = positionMatch?.balanceId; + const integrationId = positionMatch?.integrationId; + const pendingActionType = + pendingActionSelectValidatorMatch?.pendingActionType; return { balanceId: balanceId ?? null, diff --git a/packages/widget/src/features/position-details/state/workflow.ts b/packages/widget/src/features/position-details/state/workflow.ts index 797f36c2c..41e41dc56 100644 --- a/packages/widget/src/features/position-details/state/workflow.ts +++ b/packages/widget/src/features/position-details/state/workflow.ts @@ -4,7 +4,10 @@ import * as Atom from "effect/reactivity/Atom"; import type { PendingActionStateKey } from "../../../domain/action/action-command"; import type { YieldPendingActionType } from "../../../domain/action/pending-action"; import { exactZero } from "../../../domain/finance/exact"; -import type { TokenAddress } from "../../../domain/identity/identifiers"; +import type { + TokenAddress, + YieldId, +} from "../../../domain/identity/identifiers"; import type { YieldBalanceType } from "../../../domain/portfolio/positions"; import type { Token } from "../../../domain/token/token"; import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope"; @@ -89,7 +92,7 @@ export class PositionDetailsWorkflowKey extends Data.TaggedClass( "PositionDetailsWorkflowKey" )<{ readonly balanceId: string | null; - readonly integrationId: string | null; + readonly integrationId: YieldId | null; readonly pendingActionType: YieldPendingActionType | null; readonly scope: WalletScopeKey; }> {} diff --git a/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx b/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx index f828828c4..d7301f6a5 100644 --- a/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx +++ b/packages/widget/src/features/position-details/ui/compact/position-details.page.tsx @@ -1,8 +1,8 @@ import { useTranslation } from "react-i18next"; -import { Navigate } from "react-router"; import { isCancellingBalance } from "../../../../domain/earn/confidential"; import { getExtendedYieldType } from "../../../../domain/earn/yield"; import { groupPositionBalanceRows } from "../../../../domain/portfolio/positions"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { formatRewardRatePercent, formatTokenAmount, @@ -14,6 +14,7 @@ import { Spinner } from "../../../../shared/ui/primitives/spinner"; import { Heading } from "../../../../shared/ui/primitives/typography/heading"; import { Text } from "../../../../shared/ui/primitives/typography/text"; import { useWidgetPresentation } from "../../../../shared/ui/widget-presentation"; +import { WidgetNavigate } from "../../../navigation/views"; import { useTrackPage } from "../../../tracking/index"; import { AnimationPage, PageContainer } from "../../../widget-shell/views"; import { RewardRateBreakdown } from "../../../yield-summary/views"; @@ -59,7 +60,7 @@ const PositionDetails = () => { const { locale } = useWidgetPresentation(); if (!isLoading && positionLost) { - return ; + return ; } const balanceRows = positionBalancesByTypeValue diff --git a/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts b/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts index 7b4794ad5..01f78843d 100644 --- a/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts +++ b/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts @@ -1,8 +1,9 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import type { YieldId } from "../../../../../domain/identity/identifiers"; +import { widgetRoutes } from "../../../../../services/navigation/widget-routes"; +import { useWidgetRouteMatch } from "../../../../navigation/index"; import { useWalletScopeRoute } from "../../../../wallet/index"; import { usePositionDetails } from "../../../react/use-position-details"; -import { usePositionDetailsStakeMatch } from "../../../react/use-position-details-stake-match"; import { PositionDetailsStakeEntryKey } from "../../../state/stake-entry"; import { hidePositionDetailsStakeTokenBalanceAtom, @@ -18,10 +19,10 @@ import { export const usePositionDetailsStake = () => { const walletScope = useWalletScopeRoute(); - const match = usePositionDetailsStakeMatch(); + const match = useWidgetRouteMatch(widgetRoutes.positionDetails); const key = new PositionDetailsStakeEntryKey({ - balanceId: match?.params.balanceId ?? "", - integrationId: (match?.params.integrationId ?? "") as YieldId, + balanceId: match?.balanceId ?? "", + integrationId: (match?.integrationId ?? "") as YieldId, walletScope, }); const view = useAtomValue(positionDetailsStakeViewAtom(key)); diff --git a/packages/widget/src/features/position-details/ui/dashboard/index.tsx b/packages/widget/src/features/position-details/ui/dashboard/index.tsx index 1ea5baadf..39290cbd1 100644 --- a/packages/widget/src/features/position-details/ui/dashboard/index.tsx +++ b/packages/widget/src/features/position-details/ui/dashboard/index.tsx @@ -1,10 +1,13 @@ import { useTranslation } from "react-i18next"; -import { Navigate, Outlet, useMatch } from "react-router"; +import { Outlet } from "react-router"; +import { widgetRoutes } from "../../../../services/navigation/widget-routes"; import { PositionDetailsBreadcrumb as PositionDetailsBreadcrumbLayout, PositionDetailsPane, } from "../../../../shared/ui/components/position-details"; import { Box } from "../../../../shared/ui/primitives/box"; +import { useWidgetRoutePrefixMatch } from "../../../navigation/index"; +import { WidgetNavigate } from "../../../navigation/views"; import { AnimationPage, BackButton, @@ -36,18 +39,19 @@ const PositionBreadcrumb = ({ const PositionDetailsPageComponent = () => { const { t } = useTranslation(); const positionDetails = usePositionDetails(); - const flowSegment = useMatch("positions/:integrationId/:balanceId/:flow/*") - ?.params.flow; + const stakeFlow = useWidgetRoutePrefixMatch(widgetRoutes.positionStake); + const unstakeFlow = useWidgetRoutePrefixMatch(widgetRoutes.positionUnstake); + const pendingActionFlow = useWidgetRoutePrefixMatch( + widgetRoutes.positionPendingAction + ); const layout = resolvePositionDetailsPageLayout({ ...positionDetails, flowActive: - flowSegment === "stake" || - flowSegment === "unstake" || - flowSegment === "pending-action", + stakeFlow !== null || unstakeFlow !== null || pendingActionFlow !== null, }); if (layout === "PositionLost") { - return ; + return ; } const shouldShowActions = layout === "Actions"; diff --git a/packages/widget/src/features/wallet/react/wallet-scope-route.tsx b/packages/widget/src/features/wallet/react/wallet-scope-route.tsx index 0950af04d..51982f7ca 100644 --- a/packages/widget/src/features/wallet/react/wallet-scope-route.tsx +++ b/packages/widget/src/features/wallet/react/wallet-scope-route.tsx @@ -1,12 +1,14 @@ import { useAtomValue } from "@effect/atom-react"; import { createContext, useContext, useState } from "react"; -import { Navigate, Outlet } from "react-router"; +import { Outlet } from "react-router"; import { sameWalletScopeOwner, type WalletScopeKey, } from "../../../domain/wallet/wallet-scope"; +import type { WidgetPath } from "../../../services/navigation/widget-routes"; import { walletScopeFromState } from "../../../services/wallet/wallet-scope-adapter"; import type { NormalizedWalletState } from "../../../services/wallet/wallet-state"; +import { WidgetNavigate } from "../../navigation/views"; import { currentWalletStateAtom } from "../state/selectors"; const WalletScopeRouteContext = createContext(null); @@ -15,7 +17,7 @@ export const WalletScopeRoute = ({ fallbackPath, walletState, }: { - readonly fallbackPath: string; + readonly fallbackPath: WidgetPath; readonly walletState: NormalizedWalletState; }) => { const walletScope = walletScopeFromState(walletState); @@ -31,7 +33,7 @@ export const WalletScopeRoute = ({ (initialWalletScope !== null && !sameWalletScopeOwner(initialWalletScope, walletScope)) ) { - return ; + return ; } return ( @@ -44,7 +46,7 @@ export const WalletScopeRoute = ({ export const WalletScopeRouteGuard = ({ fallbackPath, }: { - readonly fallbackPath: string; + readonly fallbackPath: WidgetPath; }) => { const walletState = useAtomValue(currentWalletStateAtom); diff --git a/packages/widget/src/features/widget-shell/composition.ts b/packages/widget/src/features/widget-shell/composition.ts index 4e24feb04..cbdea01fd 100644 --- a/packages/widget/src/features/widget-shell/composition.ts +++ b/packages/widget/src/features/widget-shell/composition.ts @@ -1,4 +1,5 @@ export { AnimationLayout } from "./ui/animation-layout"; +export type { PageTransition } from "./ui/animation-page"; export { AppContainer } from "./ui/app-container"; export { CompactLayout } from "./ui/compact/compact-layout"; export { Details } from "./ui/compact/details/details.page.tsx"; diff --git a/packages/widget/src/features/widget-shell/react/use-details-match.ts b/packages/widget/src/features/widget-shell/react/use-details-match.ts index 9a285e4d6..45707bfff 100644 --- a/packages/widget/src/features/widget-shell/react/use-details-match.ts +++ b/packages/widget/src/features/widget-shell/react/use-details-match.ts @@ -1,9 +1,13 @@ -import { useMatch } from "react-router"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; +import { useWidgetRouteMatch } from "../../navigation/index"; +/** True on the Compact tab pages: Earn, Positions, and Activity. */ export const useDetailsMatch = () => { - const rootMatch = useMatch("/"); - const positionsMatch = useMatch("/positions"); - const activityMatch = useMatch("/activity"); + const earnMatch = useWidgetRouteMatch(widgetRoutes.earn); + const positionsMatch = useWidgetRouteMatch(widgetRoutes.positions); + const activityMatch = useWidgetRouteMatch(widgetRoutes.activity); - return rootMatch || positionsMatch || activityMatch; + return ( + earnMatch !== null || positionsMatch !== null || activityMatch !== null + ); }; diff --git a/packages/widget/src/features/widget-shell/react/use-header.ts b/packages/widget/src/features/widget-shell/react/use-header.ts index 66edca564..03cd48d18 100644 --- a/packages/widget/src/features/widget-shell/react/use-header.ts +++ b/packages/widget/src/features/widget-shell/react/use-header.ts @@ -1,9 +1,9 @@ import { useAtomSet } from "@effect/atom-react"; import { Option } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; -import { useNavigate } from "react-router"; import { shouldShowDisconnect } from "../../../services/wallet/wallet-connectors"; import { useLeaveConfirmation } from "../../leave-confirmation/index"; +import { useWidgetNavigate } from "../../navigation/index"; import { useTrackEvent } from "../../tracking/index"; import { useSKWallet, useWalletConfig } from "../../wallet/index"; import { useWidgetConfig } from "../../widget-configuration/index"; @@ -13,7 +13,7 @@ import { useDetailsMatch } from "./use-details-match"; import { useElementAtomRef } from "./use-element-atom-ref"; export const useHeader = () => { - const navigate = useNavigate(); + const navigation = useWidgetNavigate(); const headerRef = useElementAtomRef(headerElementAtom); @@ -41,7 +41,7 @@ export const useHeader = () => { if (!showBack) return; trackEvent("backClicked"); - navigate(-1); + navigation.back(); }; const disconnectWidget = useAtomSet(disconnectWidgetAtom); diff --git a/packages/widget/src/features/widget-shell/ui/animation-page.tsx b/packages/widget/src/features/widget-shell/ui/animation-page.tsx index b38418a17..0982b93e2 100644 --- a/packages/widget/src/features/widget-shell/ui/animation-page.tsx +++ b/packages/widget/src/features/widget-shell/ui/animation-page.tsx @@ -7,31 +7,11 @@ export type PageTransition = Pick< "animate" | "exit" | "initial" >; -export const compactPageTransition = { - initial: { opacity: 0, scale: 1, translateY: "-15px" }, - animate: { - opacity: 1, - translateY: 0, - transition: { duration: 0.5, delay: 0.1 }, - }, - exit: { - opacity: 0, - filter: "blur(8px)", - scale: 0.8, - transition: { duration: 0.4 }, - }, -} satisfies PageTransition; - -export const dashboardPageTransition = { - initial: { opacity: 0 }, - animate: { opacity: 1, transition: { duration: 0.4, delay: 0.05 } }, - exit: { opacity: 0, filter: "blur(8px)", transition: { duration: 0.1 } }, -} satisfies PageTransition; - -/** The page enter and exit animation; the app container sets it per Layout. */ -export const PageTransitionContext = createContext( - compactPageTransition -); +/** + * The page enter and exit animation; the app container provides it. Without a + * provider, pages render without a transition. + */ +export const PageTransitionContext = createContext({}); export const AnimationPage = ({ children }: PropsWithChildren) => { const transition = useContext(PageTransitionContext); diff --git a/packages/widget/src/features/widget-shell/ui/app-container.tsx b/packages/widget/src/features/widget-shell/ui/app-container.tsx index dd23c8b83..4579dd44c 100644 --- a/packages/widget/src/features/widget-shell/ui/app-container.tsx +++ b/packages/widget/src/features/widget-shell/ui/app-container.tsx @@ -1,5 +1,4 @@ import type { PropsWithChildren } from "react"; -import type { WidgetLayout } from "../../../services/config/widget-config-model"; import { type LayoutAttributeValue, layoutAttribute, @@ -7,46 +6,28 @@ import { import { Box } from "../../../shared/ui/primitives/box"; import { useElementAtomRef } from "../react/use-element-atom-ref"; import { appContainerElementAtom } from "../state/app-container"; -import { - compactPageTransition, - dashboardPageTransition, - type PageTransition, - PageTransitionContext, -} from "./animation-page"; +import { type PageTransition, PageTransitionContext } from "./animation-page"; import { appContainer } from "./layout.css"; /** - * How the shell presents each Layout: the Layout attribute that scopes - * Layout-specific styles, and the page enter and exit animation. + * The shell's root element: scopes Layout-specific styles through the Layout + * attribute and provides the page enter and exit animation. */ -const layoutPresentation: Readonly< - Record< - WidgetLayout, - { - readonly attribute: LayoutAttributeValue; - readonly pageTransition: PageTransition; - } - > -> = { - Compact: { attribute: "compact", pageTransition: compactPageTransition }, - Dashboard: { - attribute: "dashboard", - pageTransition: dashboardPageTransition, - }, -}; - export const AppContainer = ({ layout, + pageTransition, children, -}: PropsWithChildren<{ readonly layout: WidgetLayout }>) => { +}: PropsWithChildren<{ + readonly layout: LayoutAttributeValue; + readonly pageTransition: PageTransition; +}>) => { const appContainerRef = useElementAtomRef(appContainerElementAtom); - const { attribute, pageTransition } = layoutPresentation[layout]; return ( {children} diff --git a/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx b/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx index 0d135cd2a..413207a69 100644 --- a/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx +++ b/packages/widget/src/features/widget-shell/ui/compact/details/components/tabs.tsx @@ -1,8 +1,15 @@ import { motion } from "motion/react"; -import { useLocation, useNavigate } from "react-router"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../../../../services/navigation/widget-routes"; import { Divider } from "../../../../../../shared/ui/components/divider"; import { Box } from "../../../../../../shared/ui/primitives/box"; import { useMountAnimation } from "../../../../../mount-animation/index"; +import { + useWidgetNavigate, + useWidgetRoutePrefixMatch, +} from "../../../../../navigation/index"; import { useTrackEvent } from "../../../../../tracking/index"; import { useWidgetConfig } from "../../../../../widget-configuration/index"; import { divider } from "../styles.css"; @@ -10,10 +17,10 @@ import { Tab } from "./tab"; type TabsList = "earn" | "positions" | "activity"; -const TABS_MAP = { - earn: "/", - positions: "/positions", - activity: "/activity", +const TABS_MAP: Record = { + earn: widgetRoutes.earn.build(), + positions: widgetRoutes.positions.build(), + activity: widgetRoutes.activity.build(), }; type TabsProps = { @@ -22,12 +29,13 @@ type TabsProps = { const Tabs = ({ pendingActionsCount }: TabsProps) => { const trackEvent = useTrackEvent(); - const navigate = useNavigate(); + const navigation = useWidgetNavigate(); - const location = useLocation(); + const positionsMatch = useWidgetRoutePrefixMatch(widgetRoutes.positions); + const activityMatch = useWidgetRoutePrefixMatch(widgetRoutes.activity); const getSelectedTab = (): TabsList => { - if (location.pathname.startsWith("/positions")) return "positions"; - if (location.pathname.startsWith("/activity")) return "activity"; + if (positionsMatch) return "positions"; + if (activityMatch) return "activity"; return "earn"; }; const selectedTab = getSelectedTab(); @@ -37,7 +45,7 @@ const Tabs = ({ pendingActionsCount }: TabsProps) => { trackEvent("tabClicked", { selected }); - navigate(TABS_MAP[selected]); + navigation.push(TABS_MAP[selected]); }; return ( diff --git a/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts b/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts index c2c3c24a3..c78f1df5c 100644 --- a/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts +++ b/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts @@ -27,7 +27,10 @@ import type { } from "../../../domain/earn/models"; import type { ValidatorKey } from "../../../domain/earn/validator"; import { exactDecimal } from "../../../domain/finance/exact"; -import type { ProviderOption } from "../../../domain/identity/identifiers"; +import type { + ProviderOption, + YieldId, +} from "../../../domain/identity/identifiers"; import type { YieldBalanceType } from "../../../domain/portfolio/positions"; import type { Token } from "../../../domain/token/token"; import { @@ -35,10 +38,11 @@ import { WalletScopeKey, } from "../../../domain/wallet/wallet-scope"; import { - toWidgetPath, + type WidgetFlowDestination, type WidgetPath, - type WidgetPathInput, -} from "../../../services/navigation/widget-navigation"; + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../../services/navigation/widget-routes"; import { ConfidentialTransactionWorkflowInput, makeDirectTransactionLedger, @@ -114,13 +118,13 @@ export type YieldTransactionFlowIntake = | ManageYieldTransactionFlowIntake | YieldActionContinuationIntake; -type YieldTransactionFlowDestination = Readonly<{ - readonly completePath: WidgetPath; - /** Where finishing the flow returns: the tab its mount belongs to. */ - readonly finishPath: WidgetPath; - readonly reviewPath: WidgetPath; - readonly stepsPath: WidgetPath; -}>; +type YieldTransactionFlowDestination = WidgetFlowDestination< + typeof YieldFlowNavigationState.Type +> & + Readonly<{ + /** Where finishing the flow returns: the tab its mount belongs to. */ + readonly finishPath: WidgetPath; + }>; type YieldTransactionFlowMount = | Readonly<{ @@ -130,17 +134,17 @@ type YieldTransactionFlowMount = | Readonly<{ readonly _tag: "PositionExit"; readonly balanceId: string; - readonly integrationId: string; + readonly integrationId: YieldId; }> | Readonly<{ readonly _tag: "PositionManage"; readonly balanceId: string; - readonly integrationId: string; + readonly integrationId: YieldId; }> | Readonly<{ readonly _tag: "PositionStake"; readonly balanceId: string; - readonly integrationId: string; + readonly integrationId: YieldId; }>; export type YieldTransactionFlowEnterMount = Extract< @@ -262,51 +266,28 @@ const YieldFlowNavigationState = Schema.Struct({ executionHandoff: Schema.optional(Schema.instanceOf(YieldFlowHandoff)), }); -export const makeYieldFlowNavigationState = ( - session: YieldFlowSession, - executionHandoff?: YieldFlowHandoff -): typeof YieldFlowNavigationState.Type => - executionHandoff === undefined - ? { yieldFlowSession: session } - : { yieldFlowSession: session, executionHandoff }; - -export const decodeYieldFlowNavigationState = ( - state: unknown -): Option.Option => - Schema.decodeUnknownOption(YieldFlowNavigationState)(state).pipe( - Option.map((decoded) => decoded.yieldFlowSession) - ); +export type YieldFlowMountTag = YieldTransactionFlowMount["_tag"]; -export const decodeYieldFlowExecutionHandoff = ( - state: unknown -): YieldFlowHandoff | null => - Schema.decodeUnknownOption(YieldFlowNavigationState)(state).pipe( - Option.flatMap((decoded) => - Option.fromUndefinedOr(decoded.executionHandoff) +/** + * Each mount's Review, Steps, and Complete routes, carrying the Session (and + * on Steps and Complete, the handoff) in their navigation state. + */ +export const yieldFlowRoutes = { + Earn: widgetYieldFlowRoutes.Earn.withState(YieldFlowNavigationState), + PositionExit: widgetYieldFlowRoutes.PositionExit.withState( + YieldFlowNavigationState + ), + PositionManage: widgetYieldFlowRoutes.PositionManage.withState( + YieldFlowNavigationState + ), + PositionStake: widgetYieldFlowRoutes.PositionStake.withState( + YieldFlowNavigationState + ), + YieldActionContinuation: + widgetYieldFlowRoutes.YieldActionContinuation.withState( + YieldFlowNavigationState ), - Option.getOrNull - ); - -type YieldTransactionFlowRouteBase = "" | WidgetPathInput; - -const positionFlowSegments = { - PositionExit: "unstake", - PositionManage: "pending-action", - PositionStake: "stake", -} as const; - -const makeYieldTransactionFlowDestination = ({ - finishPath, - routeBase, -}: { - readonly finishPath: WidgetPathInput; - readonly routeBase: YieldTransactionFlowRouteBase; -}): YieldTransactionFlowDestination => ({ - completePath: toWidgetPath(`${routeBase}/complete`), - finishPath: toWidgetPath(finishPath), - reviewPath: toWidgetPath(`${routeBase}/review`), - stepsPath: toWidgetPath(`${routeBase}/steps`), -}); +} as const satisfies Record; const copyYieldTransactionFlowIntake = ( intake: YieldTransactionFlowIntake, @@ -323,34 +304,42 @@ export const makeYieldFlowSession = ( command: StartYieldTransactionFlow, walletScope: WalletScopeKey ): YieldFlowSession => { - const destination = (() => { + const destination = ((): YieldTransactionFlowDestination => { switch (command.mount._tag) { case "YieldActionContinuation": { if (command.intake._tag !== "YieldActionContinuation") { throw new Error("Expected Yield Action Continuation intake."); } - const routeBase = `/activity/${encodeURIComponent( - command.intake.action.id - )}` as WidgetPathInput; return { - completePath: toWidgetPath(`${routeBase}/complete`), - finishPath: toWidgetPath("/activity"), - reviewPath: toWidgetPath(routeBase), - stepsPath: toWidgetPath(`${routeBase}/steps`), + ...yieldFlowRoutes.YieldActionContinuation.at({ + actionId: command.intake.action.id, + }), + finishPath: widgetRoutes.activity.build(), }; } case "Earn": - return makeYieldTransactionFlowDestination({ - finishPath: "/", - routeBase: "", - }); + return { + ...yieldFlowRoutes.Earn.at(), + finishPath: widgetRoutes.earn.build(), + }; case "PositionExit": case "PositionManage": + return { + ...yieldFlowRoutes[command.mount._tag].at({ + balanceId: command.mount.balanceId, + integrationId: command.mount.integrationId, + }), + finishPath: widgetRoutes.positions.build(), + }; + // Only the Dashboard Layout mounts a position's stake flow. case "PositionStake": - return makeYieldTransactionFlowDestination({ - finishPath: "/positions", - routeBase: `/positions/${command.mount.integrationId}/${command.mount.balanceId}/${positionFlowSegments[command.mount._tag]}`, - }); + return { + ...yieldFlowRoutes.PositionStake.at("Dashboard", { + balanceId: command.mount.balanceId, + integrationId: command.mount.integrationId, + }), + finishPath: widgetRoutes.positions.build(), + }; } })(); @@ -370,30 +359,19 @@ export const makeYieldActionContinuationSession = ( intake.walletScope ); -const removeOptionalTrailingSlash = (pathname: string): string => - pathname.length > 1 && pathname.endsWith("/") - ? pathname.slice(0, -1) - : pathname; - -const yieldFlowRouteGroups = [ - /^()\/(?:review|steps|complete)$/, - /^(\/positions\/[^/]+\/[^/]+\/(?:stake|unstake|pending-action))\/(?:review|steps|complete)$/, - /^(\/activity\/[^/]+)(?:\/steps|\/complete)?$/, -]; - /** * The flow mount a pathname belongs to: Review, Steps, and Complete of one * mount share it, so a layout can keep that subtree (and its Session) mounted * across the flow's own navigation. Null outside Yield flow routes. */ -export const getYieldFlowRouteGroup = (pathname: string): string | null => { - const normalized = removeOptionalTrailingSlash(pathname); - for (const group of yieldFlowRouteGroups) { - const match = group.exec(normalized); - if (match) return match[1] || "/"; - } - return null; -}; +export const getYieldFlowRouteGroup = (pathname: string): string | null => + Option.getOrNull( + Option.firstSomeOf( + Object.values(widgetYieldFlowRoutes).map((routes) => + routes.mountKey(pathname) + ) + ) + ); type YieldTransactionFlowReviewPricingInput = { readonly token: Token; diff --git a/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx b/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx index 812f4d131..39837ad33 100644 --- a/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx +++ b/packages/widget/src/features/yield-transaction-flow/react/yield-flow-route.tsx @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { Option } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import { createContext, @@ -7,15 +6,18 @@ import { useContext, useState, } from "react"; -import { Navigate, Outlet, useLocation } from "react-router"; +import { Outlet, useLocation } from "react-router"; +import { widgetRoutes } from "../../../services/navigation/widget-routes"; +import { useWidgetRouteState } from "../../navigation/index"; +import { WidgetNavigate } from "../../navigation/views"; import { walletScopeAtom } from "../../wallet/index"; import { - decodeYieldFlowExecutionHandoff, - decodeYieldFlowNavigationState, getYieldTransactionFlowIntakeVariant, isYieldTransactionFlowWalletScopeValid, + type YieldFlowMountTag, type YieldFlowSession, type YieldTransactionFlowIntake, + yieldFlowRoutes, } from "../model/yield-transaction-flow"; import { isYieldTransactionWorkflowFinished } from "../model/yield-transaction-workflow"; import { @@ -46,9 +48,18 @@ export const useYieldFlowReview = () => ) ); +/** The handoff Steps and Complete were navigated to run, if any. */ +const useYieldFlowExecutionHandoff = () => { + const session = useYieldFlowSession(); + return ( + useWidgetRouteState(yieldFlowRoutes[session.mount._tag]) + ?.executionHandoff ?? null + ); +}; + /** The Execution of the handoff Steps and Complete were navigated to run. */ export const useYieldFlowExecution = () => { - const handoff = decodeYieldFlowExecutionHandoff(useLocation().state); + const handoff = useYieldFlowExecutionHandoff(); const session = useYieldFlowSession(); if (!handoff) throw new Error("Yield Flow Execution is unavailable."); return useAtomValue( @@ -81,16 +92,17 @@ type MountedYieldFlowSession = Readonly<{ */ export const YieldFlowRoute = ({ expected, + mount, }: { readonly expected: Exclude< YieldTransactionFlowIntake["_tag"], "YieldActionContinuation" >; + readonly mount: Exclude; }) => { const location = useLocation(); - const navigated = Option.getOrNull( - decodeYieldFlowNavigationState(location.state) - ); + const navigated = + useWidgetRouteState(yieldFlowRoutes[mount])?.yieldFlowSession ?? null; const [mounted, setMounted] = useState(null); if (navigated && navigated !== mounted?.session) { setMounted({ key: location.key, session: navigated }); @@ -100,7 +112,7 @@ export const YieldFlowRoute = ({ !mounted || !getYieldTransactionFlowIntakeVariant(mounted.session.intake, expected) ) { - return ; + return ; } return ; }; @@ -113,7 +125,7 @@ export const YieldFlowSessionRoute = ({ }) => { const walletScope = useAtomValue(walletScopeAtom); if (!isYieldTransactionFlowWalletScopeValid(session.intake, walletScope)) { - return ; + return ; } return ( @@ -127,8 +139,10 @@ export const YieldFlowSessionRoute = ({ * workflow; without an action handed off by Review, it leaves the flow. */ export const YieldFlowExecutionRoute = ({ children }: PropsWithChildren) => { - const handoff = decodeYieldFlowExecutionHandoff(useLocation().state); - if (!handoff) return ; + const handoff = useYieldFlowExecutionHandoff(); + if (!handoff) { + return ; + } return {children}; }; @@ -139,7 +153,7 @@ const ExecutionStarted = ({ children }: PropsWithChildren) => { const execution = useAtomValue(executionAtom); const state = useAtomValue(stateAtom); if (AsyncResult.isFailure(execution) || AsyncResult.isFailure(state)) { - return ; + return ; } if (AsyncResult.isInitial(execution)) return null; return children ?? ; @@ -150,21 +164,28 @@ export const YieldFlowExecutionPage = ({ children, page, }: PropsWithChildren<{ readonly page: "Complete" | "Steps" }>) => { - const { destination } = useYieldFlowSession(); - const location = useLocation(); + const session = useYieldFlowSession(); + const handoff = useYieldFlowExecutionHandoff(); const state = useAtomValue(useYieldFlowExecution().stateAtom); // Confidential steps run before the workflow has a state. const finished = AsyncResult.isSuccess(state) && isYieldTransactionWorkflowFinished(state.value); + const navigationState = { + ...(handoff ? { executionHandoff: handoff } : {}), + yieldFlowSession: session, + }; if (page === "Steps" && finished) { return ( - + ); } if (page === "Complete" && !finished) { return ( - + ); } return children ?? ; diff --git a/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts b/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts index 6dcdd5663..7bd6e9100 100644 --- a/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts +++ b/packages/widget/src/features/yield-transaction-flow/state/execution-page.ts @@ -69,8 +69,8 @@ export const leaveYieldFlowExecution = ( to: "Exit" | "Review" ) => to === "Review" - ? navigate({ _tag: "Replace", path: destination.reviewPath }) - : navigate({ _tag: "Push", path: destination.finishPath }); + ? navigate({ _tag: "Replace", to: destination.reviewPath }) + : navigate({ _tag: "Push", to: destination.finishPath }); /** One handoff's Execution in a Session. */ export class YieldFlowExecutionKey extends Data.Class<{ diff --git a/packages/widget/src/features/yield-transaction-flow/state/review-page.ts b/packages/widget/src/features/yield-transaction-flow/state/review-page.ts index 624037dc0..a3cedefe0 100644 --- a/packages/widget/src/features/yield-transaction-flow/state/review-page.ts +++ b/packages/widget/src/features/yield-transaction-flow/state/review-page.ts @@ -46,7 +46,6 @@ import { getYieldTransactionFlowGasWarningInput, getYieldTransactionFlowKycYield, getYieldTransactionFlowReviewPricingInput, - makeYieldFlowNavigationState, type YieldFlowExecutionSource, YieldFlowHandoff, type YieldFlowSession, @@ -192,8 +191,10 @@ export const handOffYieldFlowSource = Effect.fn("handOffYieldFlowSource")( navigation .execute({ _tag: "Push", - path: destination.stepsPath, - state: makeYieldFlowNavigationState(session, next), + to: destination.steps({ + executionHandoff: next, + yieldFlowSession: session, + }), }) .pipe( Effect.tapError(() => diff --git a/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts b/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts index 5afc77052..dbe504ea7 100644 --- a/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts +++ b/packages/widget/src/features/yield-transaction-flow/state/start-flow.ts @@ -8,7 +8,6 @@ import { walletScopeFromState } from "../../../services/wallet/wallet-scope-adap import { WalletService } from "../../../services/wallet/wallet-service"; import { isYieldTransactionFlowWalletScopeValid, - makeYieldFlowNavigationState, makeYieldFlowSession, type NavigatingYieldTransactionFlowStart, type YieldFlowSession, @@ -53,8 +52,7 @@ export const makeStartYieldTransactionFlow = Effect.gen(function* () { yield* Effect.uninterruptible( navigation.execute({ _tag: "Push", - path: session.destination.reviewPath, - state: makeYieldFlowNavigationState(session), + to: session.destination.review({ yieldFlowSession: session }), }) ); return { _tag: "Started", session } as const; diff --git a/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx b/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx index eec94e0aa..2ad123e9a 100644 --- a/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx +++ b/packages/widget/src/features/yield-transaction-flow/ui/yield-flow-routes.tsx @@ -1,6 +1,8 @@ import { Match } from "effect"; import type { ComponentType, ReactNode } from "react"; import { Route } from "react-router"; +import { widgetYieldFlowRoutes } from "../../../services/navigation/widget-routes"; +import type { YieldFlowMountTag } from "../model/yield-transaction-flow"; import { YieldFlowExecutionPage, YieldFlowExecutionRoute, @@ -17,7 +19,7 @@ import { StakeStepsPage } from "./steps/pages/stake-steps.page.tsx"; import { UnstakeStepsPage } from "./steps/pages/unstake-steps.page.tsx"; type YieldFlowRouteMount = { - readonly journey: "Enter" | "Exit" | "Manage"; + readonly mount: Exclude; /** * Layout rendered inside the Session (it may read the Session intake) around * Review, Steps, and Complete; it renders their ``. @@ -32,15 +34,17 @@ type StandardYieldFlowPages = Readonly<{ }>; const createStandardYieldFlowRoutes = ( - { journey, layout }: YieldFlowRouteMount, + { layout, mount }: YieldFlowRouteMount, + journey: "Enter" | "Exit" | "Manage", { CompletePage, ReviewPage, StepsPage }: StandardYieldFlowPages ) => { + const routes = widgetYieldFlowRoutes[mount]; const pages = ( <> - } /> + } /> }> @@ -48,7 +52,7 @@ const createStandardYieldFlowRoutes = ( } /> @@ -60,30 +64,31 @@ const createStandardYieldFlowRoutes = ( ); return ( - }> + }> {layout ? {pages} : pages} ); }; -export const createYieldFlowRoutes = (mount: YieldFlowRouteMount) => - Match.value(mount.journey).pipe( - Match.when("Enter", () => - createStandardYieldFlowRoutes(mount, { +/** Review, Steps, and Complete of the Yield Transaction Flow `mount` starts. */ +export const createYieldFlowRoutes = (route: YieldFlowRouteMount) => + Match.value(route.mount).pipe( + Match.whenOr("Earn", "PositionStake", () => + createStandardYieldFlowRoutes(route, "Enter", { CompletePage: StakeCompletePage, ReviewPage: StakeReviewPage, StepsPage: StakeStepsPage, }) ), - Match.when("Exit", () => - createStandardYieldFlowRoutes(mount, { + Match.when("PositionExit", () => + createStandardYieldFlowRoutes(route, "Exit", { CompletePage: UnstakeCompletePage, ReviewPage: UnstakeReviewPage, StepsPage: UnstakeStepsPage, }) ), - Match.when("Manage", () => - createStandardYieldFlowRoutes(mount, { + Match.when("PositionManage", () => + createStandardYieldFlowRoutes(route, "Manage", { CompletePage: PendingCompletePage, ReviewPage: PendingReviewPage, StepsPage: PendingStepsPage, diff --git a/packages/widget/src/services/navigation/application-router.ts b/packages/widget/src/services/navigation/application-router.ts index 2eb59b3c1..a96605576 100644 --- a/packages/widget/src/services/navigation/application-router.ts +++ b/packages/widget/src/services/navigation/application-router.ts @@ -2,16 +2,22 @@ import { Context, Effect, Layer, Queue, Stream } from "effect"; import { createMemoryRouter, type DataRouter, + type Location, type RouteObject, } from "react-router"; -const pathnameStream = (router: DataRouter): Stream.Stream => - Stream.callback( +/** The router's current location; match its pathname with registry routes. */ +export type ApplicationLocation = Readonly>; + +const locationStream = ( + router: DataRouter +): Stream.Stream => + Stream.callback( (queue) => Effect.acquireRelease( Effect.sync(() => { const publish = (state: DataRouter["state"]) => { - Queue.offerUnsafe(queue, state.location.pathname); + Queue.offerUnsafe(queue, { pathname: state.location.pathname }); }; const unsubscribe = router.subscribe(publish); publish(router.state); @@ -28,7 +34,7 @@ const pathnameStream = (router: DataRouter): Stream.Stream => export class ApplicationRouter extends Context.Service< ApplicationRouter, { - readonly pathnames: Stream.Stream; + readonly locations: Stream.Stream; readonly router: DataRouter; } >()("@stakekit/widget/services/navigation/ApplicationRouter") { @@ -43,10 +49,10 @@ export class ApplicationRouter extends Context.Service< Effect.sync(() => createMemoryRouter([...routes], options)), (router) => Effect.sync(() => router.dispose()) ); - const pathnames = yield* pathnameStream(router).pipe( + const locations = yield* locationStream(router).pipe( Stream.share({ capacity: 1, replay: 1, strategy: "sliding" }) ); - return ApplicationRouter.of({ pathnames, router }); + return ApplicationRouter.of({ locations, router }); }) ); } diff --git a/packages/widget/src/services/navigation/widget-navigation.ts b/packages/widget/src/services/navigation/widget-navigation.ts index 6c6cde017..08d0e641c 100644 --- a/packages/widget/src/services/navigation/widget-navigation.ts +++ b/packages/widget/src/services/navigation/widget-navigation.ts @@ -1,16 +1,13 @@ -import { Context, Data, Effect, Layer, Schema } from "effect"; +import { Context, Data, Effect, Layer } from "effect"; import { ApplicationRouter } from "./application-router"; - -export const WidgetPath = Schema.TemplateLiteral([ - Schema.Literal("/"), - Schema.String, -]).pipe(Schema.brand("WidgetPath")); -export type WidgetPath = typeof WidgetPath.Type; -export type WidgetPathInput = typeof WidgetPath.Encoded; +import { + type WidgetNavigationTarget, + widgetTargetPath, + widgetTargetState, +} from "./widget-routes"; export type WidgetNavigationOptions = Readonly<{ readonly scroll?: "preserve" | "reset"; - readonly state?: unknown; }>; export type WidgetNavigationCommand = @@ -22,13 +19,13 @@ export type WidgetNavigationCommand = | Readonly< { readonly _tag: "Push"; - readonly path: WidgetPath; + readonly to: WidgetNavigationTarget; } & WidgetNavigationOptions > | Readonly< { readonly _tag: "Replace"; - readonly path: WidgetPath; + readonly to: WidgetNavigationTarget; } & WidgetNavigationOptions >; @@ -38,8 +35,6 @@ export class WidgetNavigationError extends Data.TaggedError( readonly cause: unknown; }> {} -export const toWidgetPath = Schema.decodeSync(WidgetPath); - const resetScroll = async ( options: WidgetNavigationOptions, canResetScroll: () => boolean @@ -65,11 +60,11 @@ export type WidgetNavigationService = Readonly<{ command: WidgetNavigationCommand ) => Effect.Effect; readonly push: ( - path: WidgetPath, + to: WidgetNavigationTarget, options?: WidgetNavigationOptions ) => Effect.Effect; readonly replace: ( - path: WidgetPath, + to: WidgetNavigationTarget, options?: WidgetNavigationOptions ) => Effect.Effect; }>; @@ -83,9 +78,9 @@ export const makeWidgetNavigation = ( case "Back": return methods.back(command); case "Push": - return methods.push(command.path, command); + return methods.push(command.to, command); case "Replace": - return methods.replace(command.path, command); + return methods.replace(command.to, command); } }, }); @@ -103,23 +98,22 @@ export class WidgetNavigation extends Context.Service< await resetScroll(options, canResetScroll); await router.navigate(-1); }); - const push: WidgetNavigation["Service"]["push"] = ( - path, - options = {} - ) => + const push: WidgetNavigation["Service"]["push"] = (to, options = {}) => runNavigation(async () => { await resetScroll(options, canResetScroll); - await router.navigate(path, { state: options.state }); + await router.navigate(widgetTargetPath(to), { + state: widgetTargetState(to), + }); }); const replace: WidgetNavigation["Service"]["replace"] = ( - path, + to, options = {} ) => runNavigation(async () => { await resetScroll(options, canResetScroll); - await router.navigate(path, { + await router.navigate(widgetTargetPath(to), { replace: true, - state: options.state, + state: widgetTargetState(to), }); }); return makeWidgetNavigation({ back, push, replace }); diff --git a/packages/widget/src/services/navigation/widget-routes.ts b/packages/widget/src/services/navigation/widget-routes.ts new file mode 100644 index 000000000..f1a641ad4 --- /dev/null +++ b/packages/widget/src/services/navigation/widget-routes.ts @@ -0,0 +1,408 @@ +import { Option, Schema } from "effect"; +import { YieldPendingActionType } from "../../domain/action/pending-action"; +import { ActionId as BorrowActionId, MarketId } from "../../domain/borrow/ids"; +import { ActionId, YieldId } from "../../domain/identity/identifiers"; +import type { WidgetLayout } from "../config/widget-config-model"; + +/** + * The Widget's route registry: one definition per route. Every absolute path + * the Widget renders, navigates to, or matches comes from here; route trees, + * navigation, and pathname parsing elsewhere use these definitions. + */ + +declare const widgetPathBrand: unique symbol; + +/** An absolute Widget path, built only by a registry route. */ +export type WidgetPath = string & { readonly [widgetPathBrand]: true }; + +declare const widgetLocationBrand: unique symbol; + +/** A path together with the navigation state its route carries. */ +type WidgetLocation = Readonly<{ + readonly path: WidgetPath; + readonly state: State; + readonly [widgetLocationBrand]: true; +}>; + +/** Where a navigation goes: a path, or a path with its route's state. */ +export type WidgetNavigationTarget = WidgetPath | WidgetLocation; + +export const widgetTargetPath = (target: WidgetNavigationTarget): WidgetPath => + typeof target === "string" ? target : target.path; + +export const widgetTargetState = (target: WidgetNavigationTarget): unknown => + typeof target === "string" ? undefined : target.state; + +const allLayouts = [ + "Compact", + "Dashboard", +] as const satisfies ReadonlyArray; +/** Layouts wide enough for Borrow and position staking. */ +const wideLayouts = [ + "Dashboard", +] as const satisfies ReadonlyArray; + +type ParamSchema = Schema.Codec; + +type ParamNames = + Pattern extends `${string}:${infer Name}/${infer Rest}` + ? Name | ParamNames + : Pattern extends `${string}:${infer Name}` + ? Name + : never; + +type ParamSchemas = { + readonly [Name in ParamNames]?: ParamSchema; +}; + +type RouteParams = { + readonly [Name in ParamNames]: Name extends keyof Schemas + ? Schemas[Name] extends { readonly Type: infer Value } + ? Value + : string + : string; +}; + +/** + * A route mounted in only some Layouts takes the Layout it is built for, so + * navigating to it states which Layout it expects. + */ +type LayoutArgs = [WidgetLayout] extends [Layout] + ? [] + : [layout: Layout]; + +type ParamArgs = [keyof Params] extends [never] ? [] : [params: Params]; + +type WidgetRouteBuildArgs = [ + ...LayoutArgs, + ...ParamArgs, +]; + +type Segment = + | Readonly<{ readonly _tag: "Param"; readonly name: string }> + | Readonly<{ readonly _tag: "Static"; readonly value: string }>; + +const parseSegments = (pattern: string): ReadonlyArray => + pattern + .split("/") + .filter((segment) => segment !== "") + .map((segment) => + segment.startsWith(":") + ? { _tag: "Param", name: segment.slice(1) } + : { _tag: "Static", value: segment.toLowerCase() } + ); + +const pathSegments = (pathname: string): ReadonlyArray => + pathname.split("/").filter((segment) => segment !== ""); + +const decodeSegment = (segment: string): Option.Option => { + try { + return Option.some(decodeURIComponent(segment)); + } catch { + return Option.none(); + } +}; + +const findParams = ( + args: ReadonlyArray +): Readonly> => { + const params = args.find((arg) => typeof arg === "object" && arg !== null); + return (params ?? {}) as Readonly>; +}; + +/** True when `pathname` is exactly `path`, ignoring a trailing slash. */ +export const isWidgetPath = (pathname: string, path: WidgetPath): boolean => + pathSegments(pathname).join("/") === pathSegments(path).join("/"); + +class WidgetRoute< + const Pattern extends string, + Params, + Layout extends WidgetLayout, +> { + /** Every path inside this route, for catch-all redirects. */ + readonly descendants: `${Pattern}/*`; + readonly paramNames: ReadonlyArray; + readonly #segments: ReadonlyArray; + + constructor( + /** The absolute route pattern route trees mount. */ + readonly pattern: Pattern, + /** The Layouts whose route trees mount this route. */ + readonly layouts: ReadonlyArray, + /** Decoders of the params with a domain schema. */ + readonly paramSchemas: Readonly> + ) { + this.descendants = `${pattern}/*`; + this.#segments = parseSegments(pattern); + this.paramNames = this.#segments.flatMap((segment) => + segment._tag === "Param" ? [segment.name] : [] + ); + } + + build(...args: WidgetRouteBuildArgs): WidgetPath { + const params = findParams(args); + const segments = this.#segments.map((segment) => + segment._tag === "Param" + ? encodeURIComponent(params[segment.name] ?? "") + : segment.value + ); + return `/${segments.join("/")}` as WidgetPath; + } + + /** Binds the navigation state this route's owner carries with its path. */ + withState(schema: Schema.Codec) { + return { + build: ( + state: State, + ...args: WidgetRouteBuildArgs + ): WidgetLocation => + ({ path: this.build(...args), state }) as WidgetLocation, + state: Schema.decodeUnknownOption(schema), + }; + } + + /** This route's params when `pathname` is exactly this route. */ + match(pathname: string): Option.Option { + return this.#match(pathSegments(pathname), true); + } + + /** This route's params when `pathname` is this route or inside it. */ + matchPrefix(pathname: string): Option.Option { + return this.#match(pathSegments(pathname), false); + } + + #match( + segments: ReadonlyArray, + exact: boolean + ): Option.Option { + if ( + segments.length < this.#segments.length || + (exact && segments.length !== this.#segments.length) + ) { + return Option.none(); + } + const params: Record = {}; + for (const [index, segment] of this.#segments.entries()) { + const value = segments[index] ?? ""; + if (segment._tag === "Static") { + if (value.toLowerCase() !== segment.value) return Option.none(); + continue; + } + const decoded = decodeSegment(value).pipe( + Option.flatMap((raw) => { + const schema = this.paramSchemas[segment.name]; + return schema ? Schema.decodeOption(schema)(raw) : Option.some(raw); + }) + ); + if (Option.isNone(decoded)) return Option.none(); + params[segment.name] = decoded.value; + } + return Option.some(params as Params); + } +} + +/** One mount's Review, Steps, and Complete paths. */ +type WidgetFlowPaths = Readonly<{ + readonly completePath: WidgetPath; + readonly reviewPath: WidgetPath; + readonly stepsPath: WidgetPath; +}>; + +/** One mount's flow paths, and their locations carrying the flow state. */ +export type WidgetFlowDestination = WidgetFlowPaths & + Readonly<{ + readonly complete: (state: State) => WidgetLocation; + readonly review: (state: State) => WidgetLocation; + readonly steps: (state: State) => WidgetLocation; + }>; + +/** The route a flow mounts under, in whichever Layouts mount it. */ +type WidgetFlowBase = Readonly<{ + readonly build: (...args: never) => WidgetPath; + readonly match: (pathname: string) => Option.Option; + readonly paramSchemas: Readonly>; + readonly pattern: string; +}>; + +class WidgetFlowRoutes { + constructor( + /** The route the flow mounts under. */ + readonly base: WidgetFlowBase, + readonly review: WidgetRoute, + readonly steps: WidgetRoute, + readonly complete: WidgetRoute + ) {} + + at(...args: WidgetRouteBuildArgs): WidgetFlowPaths { + return { + completePath: this.complete.build(...args), + reviewPath: this.review.build(...args), + stepsPath: this.steps.build(...args), + }; + } + + /** + * The base path of the mount a pathname shows, when it is one of this + * group's pages: its Review, Steps, and Complete share it. + */ + mountKey(pathname: string): Option.Option { + // The key names a mount, not a navigation target, so it needs no Layout. + const buildBase = this.base.build.bind(this.base) as unknown as ( + params: Params + ) => WidgetPath; + return Option.firstSomeOf([ + this.review.match(pathname), + this.steps.match(pathname), + this.complete.match(pathname), + ]).pipe(Option.map(buildBase)); + } + + /** Binds the navigation state the flow's pages carry. */ + withState( + schema: Schema.Codec + ): StatefulWidgetFlowRoutes { + return new StatefulWidgetFlowRoutes( + this, + Schema.decodeUnknownOption(schema) + ); + } +} + +/** A flow group with the navigation state its owner bound to it. */ +class StatefulWidgetFlowRoutes { + constructor( + readonly routes: WidgetFlowRoutes, + /** Decodes a location's state as the flow's state. */ + readonly state: (locationState: unknown) => Option.Option + ) {} + + at( + ...args: WidgetRouteBuildArgs + ): WidgetFlowDestination { + const paths = this.routes.at(...args); + const located = + (path: WidgetPath) => + (state: State): WidgetLocation => + ({ path, state }) as WidgetLocation; + return { + ...paths, + complete: located(paths.completePath), + review: located(paths.reviewPath), + steps: located(paths.stepsPath), + }; + } +} + +const route = < + const Pattern extends string, + Layout extends WidgetLayout, + const Schemas extends ParamSchemas = Record, +>( + pattern: Pattern, + layouts: ReadonlyArray, + schemas?: Schemas +) => + new WidgetRoute, Layout>( + pattern, + layouts, + (schemas ?? {}) as Readonly> + ); + +const flowRoutes = ( + base: WidgetFlowBase, + layouts: ReadonlyArray +) => { + const prefix = base.pattern === "/" ? "" : base.pattern; + const page = (name: "complete" | "review" | "steps") => + new WidgetRoute( + `${prefix}/${name}`, + layouts, + base.paramSchemas + ); + return new WidgetFlowRoutes( + base, + page("review"), + page("steps"), + page("complete") + ); +}; + +const positionParams = { integrationId: YieldId } as const; +const activityActionParams = { actionId: ActionId } as const; +const borrowPositionParams = { marketId: MarketId } as const; + +const activityAction = route( + "/activity/:actionId", + allLayouts, + activityActionParams +); + +export const widgetRoutes = { + earn: route("/", allLayouts), + positions: route("/positions", allLayouts), + positionDetails: route( + "/positions/:integrationId/:balanceId", + allLayouts, + positionParams + ), + positionSelectValidator: route( + "/positions/:integrationId/:balanceId/select-validator/:pendingActionType", + allLayouts, + { ...positionParams, pendingActionType: YieldPendingActionType } + ), + /** Base of the stake flow; Compact redirects its paths to the hub. */ + positionStake: route( + "/positions/:integrationId/:balanceId/stake", + allLayouts, + positionParams + ), + positionUnstake: route( + "/positions/:integrationId/:balanceId/unstake", + allLayouts, + positionParams + ), + positionPendingAction: route( + "/positions/:integrationId/:balanceId/pending-action", + allLayouts, + positionParams + ), + activity: route("/activity", allLayouts), + activityAction, + activityBorrowAction: route("/activity/borrow/:actionId", allLayouts, { + actionId: BorrowActionId, + }), + borrow: route("/borrow", wideLayouts), + /** Base of market positions; its unknown paths redirect to Positions. */ + positionsBorrow: route("/positions/borrow", wideLayouts), + borrowPosition: route( + "/positions/borrow/:marketId", + wideLayouts, + borrowPositionParams + ), + borrowPositionAction: route( + "/positions/borrow/:marketId/action/:actionId", + wideLayouts, + borrowPositionParams + ), +} as const; + +/** Yield Transaction Flow pages, by the mount that starts them. */ +export const widgetYieldFlowRoutes = { + Earn: flowRoutes(widgetRoutes.earn, allLayouts), + PositionStake: flowRoutes(widgetRoutes.positionStake, wideLayouts), + PositionExit: flowRoutes(widgetRoutes.positionUnstake, allLayouts), + PositionManage: flowRoutes(widgetRoutes.positionPendingAction, allLayouts), + /** A continuation's Review is the action's own details route. */ + YieldActionContinuation: new WidgetFlowRoutes( + activityAction, + activityAction, + route("/activity/:actionId/steps", allLayouts, activityActionParams), + route("/activity/:actionId/complete", allLayouts, activityActionParams) + ), +} as const; + +/** Borrow Transaction Flow pages, by the entry that starts them. */ +export const widgetBorrowFlowRoutes = { + BorrowEntry: flowRoutes(widgetRoutes.borrow, wideLayouts), + MarketPosition: flowRoutes(widgetRoutes.borrowPosition, wideLayouts), +} as const; diff --git a/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx b/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx index ac543e355..b48550cfe 100644 --- a/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx +++ b/packages/widget/tests/app/journeys/staking-flow/leave-confirmation.browser.test.tsx @@ -1,10 +1,21 @@ +import { MotionGlobalConfig } from "motion/react"; import { avalanche } from "viem/chains"; +import { afterEach } from "vitest"; import { userEvent } from "vitest/browser"; +import { YieldCategory } from "../../../../src/public-api/types"; import { formatAddress } from "../../../../src/shared/lib/general"; import { describe, expect, it, vi } from "../../../utils/test-extend"; import { renderApp } from "../../../utils/test-utils"; +import { waitForMs } from "../../../utils/wait"; import { setup } from "./setup"; +/** Outlasts the page exit transition that used to drop the dialog. */ +const pastPageExitMs = 1_000; + +afterEach(() => { + MotionGlobalConfig.skipAnimations = true; +}); + describe("Staking flow Leave Confirmation", () => { it("asks before Cancel abandons a stake the wallet is signing", async ({ worker, @@ -74,4 +85,98 @@ describe("Staking flow Leave Confirmation", () => { await app.unmount(); }); + + it("asks before a dashboard category tab abandons a stake the wallet is signing", async ({ + worker, + }) => { + MotionGlobalConfig.skipAnimations = false; + const { customConnectors, account, requestFn } = await setup(worker, { + includeDefiYield: true, + }); + // The wallet never answers the signature request: Steps stays Signing. + requestFn.mockImplementation(async ({ method }: { method: string }) => { + switch (method) { + case "eth_sendTransaction": + return Promise.withResolvers().promise; + case "eth_chainId": + return avalanche.id; + case "eth_requestAccounts": + return [account]; + default: + throw new Error("unhandled method"); + } + }); + const app = await renderApp({ + skProps: { + apiKey: import.meta.env.VITE_API_KEY, + dashboardVariant: true, + yieldCategoryOrder: [YieldCategory.Stake, YieldCategory.DeFi], + }, + walletListFactory: customConnectors, + }); + const tab = (label: string) => { + const section = app.container.querySelector("[data-rk='tabs-section']"); + const found = [...(section?.querySelectorAll("p") ?? [])].find( + (el) => el.textContent === label + ); + if (!found) throw new Error(`Missing dashboard tab: ${label}`); + return found; + }; + const tabState = (label: string) => tab(label).getAttribute("data-state"); + const signatureRequests = () => + requestFn.mock.calls.filter( + ([{ method }]) => method === "eth_sendTransaction" + ).length; + const dialog = app.getByTestId("leave-confirmation"); + const steps = app.getByText("Follow Steps"); + + await expect + .element(app.getByText(formatAddress(account))) + .toBeInTheDocument(); + await expect.poll(() => tabState("DeFi")).toBe("default"); + await expect.poll(() => tabState("Stake")).toBe("selected"); + + await userEvent.click(app.getByTestId("number-input")); + await userEvent.keyboard("0.1"); + await userEvent.click( + app.getByRole("button", { exact: true, name: "Stake" }).last() + ); + await userEvent.click( + app.getByRole("button", { exact: true, name: "Confirm" }) + ); + await expect.poll(signatureRequests).toBe(1); + await expect.element(steps).toBeInTheDocument(); + + // Pressing a category asks first; the question outlasts the page exit. + await userEvent.click(tab("DeFi")); + await expect.element(dialog).toBeInTheDocument(); + await waitForMs(pastPageExitMs); + expect(dialog.query()).not.toBeNull(); + expect(steps.query()).not.toBeNull(); + expect(tabState("Stake")).toBe("selected"); + expect(tabState("DeFi")).toBe("default"); + + // Stay keeps the signing stake and drops the pressed category for good. + await userEvent.click(app.getByRole("button", { name: "Stay" })); + await expect.element(dialog).not.toBeInTheDocument(); + await waitForMs(pastPageExitMs); + expect(dialog.query()).toBeNull(); + expect(steps.query()).not.toBeNull(); + expect(tabState("Stake")).toBe("selected"); + expect(tabState("DeFi")).toBe("default"); + expect(signatureRequests()).toBe(1); + + // A category left pending by Stay would swallow this second press. + await userEvent.click(tab("DeFi")); + await expect.element(dialog).toBeInTheDocument(); + await userEvent.click(app.getByRole("button", { name: "Leave" })); + await expect.poll(() => tabState("DeFi")).toBe("selected"); + expect(tabState("Stake")).toBe("default"); + await expect.element(steps).not.toBeInTheDocument(); + await expect.element(dialog).not.toBeInTheDocument(); + await expect.element(app.getByTestId("number-input")).toBeInTheDocument(); + expect(signatureRequests()).toBe(1); + + await app.unmount(); + }); }); diff --git a/packages/widget/tests/app/journeys/staking-flow/setup.ts b/packages/widget/tests/app/journeys/staking-flow/setup.ts index 8c25c779d..a93bd5dc6 100644 --- a/packages/widget/tests/app/journeys/staking-flow/setup.ts +++ b/packages/widget/tests/app/journeys/staking-flow/setup.ts @@ -23,7 +23,13 @@ import { waitForMs } from "../../../utils/wait"; type TransactionDto = typeof LegacyTransaction.Encoded; -export const setup = async (worker: TestWorker) => { +export const setup = async ( + worker: TestWorker, + { + /** Also lists an AVAX lending yield, so the DeFi category is available. */ + includeDefiYield = false, + }: { readonly includeDefiYield?: boolean } = {} +) => { const token: TokenDto = { name: "Avalanche C Chain", symbol: "AVAX", @@ -166,6 +172,21 @@ export const setup = async (worker: TestWorker) => { supportsLedgerWalletApi: yieldOp.metadata.supportsLedgerWalletApi, }, }); + const defiYieldOp = yieldApiYieldDtoFixture({ + ...yieldApiYieldOp, + id: "avalanche-avax-lending", + metadata: { + ...yieldApiYieldOp.metadata, + name: "AVAX Lending", + }, + mechanics: { + ...yieldApiYieldOp.mechanics, + type: "lending", + }, + }); + const catalogYields = includeDefiYield + ? [yieldApiYieldOp, defiYieldOp] + : [yieldApiYieldOp]; const enterAction = { id: "18bdda99-346a-4694-af71-58dfea68d542", integrationId: "avalanche-avax-liquid-staking", @@ -307,10 +328,10 @@ export const setup = async (worker: TestWorker) => { await mockDelay(); return HttpResponse.json({ - items: [yieldApiYieldOp], - total: 1, + items: catalogYields, + total: catalogYields.length, offset: 0, - limit: 1, + limit: catalogYields.length, }); }), http.get( @@ -410,6 +431,15 @@ export const setup = async (worker: TestWorker) => { }) ); + if (includeDefiYield) { + worker.use( + http.get(yieldApiRoute(`/v1/yields/${defiYieldOp.id}`), async () => { + await mockDelay(); + return HttpResponse.json(defiYieldOp); + }) + ); + } + const account = "0xB6c5273e79E2aDD234EBC07d87F3824e0f94B2F7"; const requestFn = vitest.fn(async ({ method }: { method: string }) => { diff --git a/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx b/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx index 9490f0ecc..2bebf1d77 100644 --- a/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx +++ b/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx @@ -6,31 +6,42 @@ import { useLocation, } from "react-router"; import { describe, expect, it } from "vitest"; +import { + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../../../src/services/navigation/widget-routes"; import { render } from "../../../utils/test-utils.dom.tsx"; /** - * Redirect contract for compact position-details mounts. + * Redirect contract for Compact position-details mounts: relative redirects + * under the registry's absolute route patterns. * Keep in sync with `src/app/layouts/compact/compact-routes.tsx`. */ const CompactPositionDetailsRedirectRoutes = () => ( - + hub} /> - + } /> } /> - + } /> - } /> + } + /> } /> - + } /> - } /> + } + /> } /> diff --git a/packages/widget/tests/app/layouts/layout-routes.test.tsx b/packages/widget/tests/app/layouts/layout-routes.test.tsx new file mode 100644 index 000000000..4b12fdafd --- /dev/null +++ b/packages/widget/tests/app/layouts/layout-routes.test.tsx @@ -0,0 +1,104 @@ +import { + createRoutesFromElements, + matchRoutes, + type RouteObject, +} from "react-router"; +import { describe, expect, it } from "vitest"; +import { createCompactRoutes } from "../../../src/app/layouts/compact/compact-routes"; +import { createDashboardRoutes } from "../../../src/app/layouts/dashboard/dashboard-routes"; +import type { WidgetLayout } from "../../../src/services/config/widget-config-model"; +import { + widgetBorrowFlowRoutes, + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../../src/services/navigation/widget-routes"; + +type RegistryRoute = Readonly<{ + readonly build: (...args: ReadonlyArray) => string; + readonly descendants: string; + readonly layouts: ReadonlyArray; + readonly paramNames: ReadonlyArray; + readonly pattern: string; +}>; + +const registryRoutes: ReadonlyArray = [ + ...Object.values(widgetRoutes), + ...[ + ...Object.values(widgetYieldFlowRoutes), + ...Object.values(widgetBorrowFlowRoutes), + ].flatMap((flow) => [flow.review, flow.steps, flow.complete]), +] as ReadonlyArray; + +const layoutRoutes: Readonly>> = + { + Compact: createRoutesFromElements(createCompactRoutes()), + Dashboard: createRoutesFromElements( + createDashboardRoutes({ registerEarnFooterButton: false }) + ), + }; + +const samplePath = (route: RegistryRoute, layout: WidgetLayout) => + route.build( + layout, + Object.fromEntries(route.paramNames.map((name) => [name, `sample-${name}`])) + ); + +/** + * The registry route a path renders in a Layout's tree: the deepest matched + * route with a path, or the root for index routes under pathless layouts. + * Null when only a catch-all matches. + */ +const mountedPattern = ( + routes: ReadonlyArray, + path: string +): string | null => { + const matches = matchRoutes([...routes], path) ?? []; + if (matches.length === 0) return null; + const withPath = matches.filter((match) => match.route.path !== undefined); + const deepest = withPath.at(-1)?.route.path ?? widgetRoutes.earn.pattern; + return deepest === "*" ? null : deepest; +}; + +const treePaths = (routes: ReadonlyArray): ReadonlyArray => + routes.flatMap((route) => [ + ...(route.path === undefined ? [] : [route.path]), + ...treePaths(route.children ?? []), + ]); + +describe.each(["Compact", "Dashboard"] as const)( + "%s Layout routes", + (layout) => { + const routes = layoutRoutes[layout]; + + it.each( + registryRoutes + .filter((route) => route.layouts.includes(layout)) + .map((route) => [route.pattern, route] as const) + )("mounts %s", (_pattern, route) => { + expect([route.pattern, route.descendants]).toContain( + mountedPattern(routes, samplePath(route, layout)) + ); + }); + + it.each( + registryRoutes + .filter((route) => !route.layouts.includes(layout)) + .map((route) => [route.pattern, route] as const) + )("does not mount %s", (_pattern, route) => { + expect([route.pattern, route.descendants]).not.toContain( + mountedPattern(routes, samplePath(route, layout)) + ); + }); + + it("mounts only registry patterns", () => { + const known = new Set([ + "*", + ...registryRoutes.flatMap((route) => [ + route.pattern, + route.descendants, + ]), + ]); + expect(treePaths(routes).filter((path) => !known.has(path))).toEqual([]); + }); + } +); diff --git a/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx b/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx index 5cc407fea..573aa22b5 100644 --- a/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx +++ b/packages/widget/tests/app/routes/borrow-feature-route.dom.test.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { MemoryRouter, Route, Routes, useLocation } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { BorrowFeatureRoute } from "../../../src/app/routes/ui/borrow-feature-route"; +import { widgetRoutes } from "../../../src/services/navigation/widget-routes"; import { render } from "../../utils/test-utils.dom.tsx"; import { TestWidgetConfigProvider } from "../../utils/widget-config-provider"; @@ -38,10 +39,20 @@ const renderRoute = ({ > - }> + + } + > } /> - }> + + } + > } /> } /> @@ -88,7 +99,11 @@ describe("BorrowFeatureRoute", () => { > - }> + + } + > } diff --git a/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts b/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts index 30a2c33df..8c64449b8 100644 --- a/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts +++ b/packages/widget/tests/app/routes/tab-resources-prefetch.test.ts @@ -5,7 +5,7 @@ import * as AtomRegistry from "effect/reactivity/AtomRegistry"; import { describe, expect, it, vi } from "vitest"; import { tabResourcesPrefetchAtom } from "../../../src/app/routes/state/tab-resources-prefetch"; import { appRuntime } from "../../../src/app/runtime/app-runtime"; -import { applicationRouterPathnameAtom } from "../../../src/app/runtime/application-router"; +import { applicationRouterLocationAtom } from "../../../src/app/runtime/application-router"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { walletConfigResultAtom, @@ -68,7 +68,7 @@ const makeRegistry = ({ AtomRegistry.make({ initialValues: [ applicationRuntimeInitInitialValue(), - Atom.initialValue(applicationRouterPathnameAtom, pathname), + Atom.initialValue(applicationRouterLocationAtom, { pathname }), Atom.initialValue( appRuntime.layer, Layer.mergeAll( diff --git a/packages/widget/tests/app/runtime/application-router.test.ts b/packages/widget/tests/app/runtime/application-router.test.ts index 1705727fc..db4f8b3bf 100644 --- a/packages/widget/tests/app/runtime/application-router.test.ts +++ b/packages/widget/tests/app/runtime/application-router.test.ts @@ -5,14 +5,17 @@ import { applicationRoutes } from "../../../src/app/routes/application-routes"; import { appRuntime } from "../../../src/app/runtime/app-runtime"; import { applicationRouterAtom, - applicationRouterPathnameAtom, + applicationRouterLocationAtom, } from "../../../src/app/runtime/application-router"; import { applicationRuntimeInitAtom } from "../../../src/app/runtime/application-runtime-init"; import { - toWidgetPath, WidgetNavigation, type WidgetNavigationCommand, } from "../../../src/services/navigation/widget-navigation"; +import { + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../../src/services/navigation/widget-routes"; const navigationCommandAtom = appRuntime .fn( @@ -50,21 +53,23 @@ describe("ApplicationRouter runtime", () => { } }); - it("publishes pathname changes through the ApplicationRouter stream", async () => { + it("publishes location changes through the ApplicationRouter stream", async () => { const registry = makeRegistry(); - const unmount = registry.mount(applicationRouterPathnameAtom); + const unmount = registry.mount(applicationRouterLocationAtom); try { - expect(registry.get(applicationRouterPathnameAtom)).toBe("/"); + expect(registry.get(applicationRouterLocationAtom).pathname).toBe("/"); registry.set(navigationCommandAtom, { _tag: "Push", - path: toWidgetPath("/positions"), + to: widgetRoutes.positions.build(), scroll: "preserve", }); await vi.waitFor(() => - expect(registry.get(applicationRouterPathnameAtom)).toBe("/positions") + expect(registry.get(applicationRouterLocationAtom).pathname).toBe( + "/positions" + ) ); } finally { unmount(); @@ -112,7 +117,7 @@ describe("ApplicationRouter runtime", () => { registry.set(navigationCommandAtom, { _tag: "Push", - path: toWidgetPath("/review"), + to: widgetYieldFlowRoutes.Earn.review.build(), scroll: "preserve", }); @@ -122,7 +127,7 @@ describe("ApplicationRouter runtime", () => { registry.set(navigationCommandAtom, { _tag: "Replace", - path: toWidgetPath("/complete"), + to: widgetYieldFlowRoutes.Earn.complete.build(), scroll: "preserve", }); @@ -148,7 +153,7 @@ describe("ApplicationRouter runtime", () => { try { firstRegistry.set(navigationCommandAtom, { _tag: "Push", - path: toWidgetPath("/review"), + to: widgetYieldFlowRoutes.Earn.review.build(), scroll: "preserve", }); await vi.waitFor(() => diff --git a/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts b/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts index 44d1c99d3..252859cfb 100644 --- a/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts +++ b/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts @@ -1,20 +1,27 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { Effect, Layer, Schema, Stream } from "effect"; +import { Effect, Layer, Option, Schema, Stream } from "effect"; import { DeepLinkCoordinator, type DeepLinkRouteObservation, } from "../../../src/app/runtime/deep-link-coordinator"; import { EarnBalance } from "../../../src/domain/earn/models"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + WalletAddress, + YieldId, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { findPendingActionDeepLinkTarget } from "../../../src/features/earn/model/pending-action-deep-link"; -import type { YieldFlowSession } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; +import { yieldFlowRoutes } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; import { type WidgetNavigationCommand, WidgetNavigationError, type WidgetNavigationOptions, - type WidgetPath, } from "../../../src/services/navigation/widget-navigation"; +import { + type WidgetPath, + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { disconnectedLedgerConnectorState, disconnectedNormalizedWalletState, @@ -32,6 +39,7 @@ const otherAddress = Schema.decodeSync(WalletAddress)( "0x0000000000000000000000000000000000000002" ); const scope = new WalletScopeKey({ address, network: "ethereum" }); +const yieldId = Schema.decodeSync(YieldId)("yield-1"); const pendingObservation = (ownerScope = scope): DeepLinkRouteObservation => ({ maintenance: false, @@ -42,7 +50,7 @@ const pendingObservation = (ownerScope = scope): DeepLinkRouteObservation => ({ mount: { _tag: "PositionManage", balanceId: "balance-1", - integrationId: "yield-1", + integrationId: yieldId, }, }, intent: { @@ -89,7 +97,9 @@ const makeCoordinatorTestKit = Effect.fn("makeCoordinatorTestKit")(function* ({ }); const navigation = yield* makeTestNavigation({ execute: (command) => - command._tag === "Push" ? navigate(command.path, command) : Effect.void, + command._tag === "Push" + ? navigate(widgetTargetPath(command.to), command) + : Effect.void, }); return { @@ -102,19 +112,25 @@ const makeCoordinatorTestKit = Effect.fn("makeCoordinatorTestKit")(function* ({ } as const; }); -/** The navigation state Start carries to Review. */ -type YieldFlowNavigationState = Readonly<{ - readonly yieldFlowSession?: YieldFlowSession; -}>; - /** The Flow Sessions Start navigated to Review with. */ const startedSessions = (commands: ReadonlyArray) => - commands.flatMap((command) => { - const state = command.state as YieldFlowNavigationState | undefined; - return command._tag === "Push" && state?.yieldFlowSession - ? [{ path: command.path, session: state.yieldFlowSession }] - : []; - }); + commands.flatMap((command) => + command._tag === "Push" + ? yieldFlowRoutes.PositionManage.state( + widgetTargetState(command.to) + ).pipe( + Option.match({ + onNone: () => [], + onSome: ({ yieldFlowSession }) => [ + { + path: widgetTargetPath(command.to), + session: yieldFlowSession, + }, + ], + }) + ) + : [] + ); const runObservations = Effect.fn("runObservations")(function* ( observations: ReadonlyArray, @@ -238,7 +254,7 @@ describe("DeepLinkCoordinator", () => { const position = { maintenance: false, pendingAction: null, - position: { balanceId: "balance-1", yieldId: "yield-1" }, + position: { balanceId: "balance-1", yieldId }, ready: true, } satisfies DeepLinkRouteObservation; @@ -270,7 +286,7 @@ describe("DeepLinkCoordinator", () => { { maintenance: false, pendingAction: null, - position: { balanceId: "balance-1", yieldId: "yield-1" }, + position: { balanceId: "balance-1", yieldId }, ready: true, }, ], diff --git a/packages/widget/tests/features/activity/activity-routes.test.tsx b/packages/widget/tests/features/activity/activity-routes.test.tsx index 85514a1aa..73bfe2676 100644 --- a/packages/widget/tests/features/activity/activity-routes.test.tsx +++ b/packages/widget/tests/features/activity/activity-routes.test.tsx @@ -2,6 +2,10 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { createRoutesFromElements, type RouteObject } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { createActivityActionRoutes } from "../../../src/features/activity/composition"; +import { + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../../src/services/navigation/widget-routes"; vi.mock("../../../src/features/activity/react/activity-action-route", () => ({ ActivityActionRoute: function ActivityActionRoute() { @@ -75,7 +79,7 @@ const borrowActionIdRoute = (actionRoute: string): RouteDescription => ({ ], element: actionRoute, index: false, - path: "borrow/:actionId", + path: widgetRoutes.activityBorrowAction.pattern, }); const actionIdRoute = (actionRoute: string): RouteDescription => ({ @@ -92,13 +96,13 @@ const actionIdRoute = (actionRoute: string): RouteDescription => ({ children: [], element: "YieldActionContinuationStepsPage", index: false, - path: "steps", + path: widgetYieldFlowRoutes.YieldActionContinuation.steps.pattern, }, { children: [], element: "YieldActionContinuationCompletePage", index: false, - path: "complete", + path: widgetYieldFlowRoutes.YieldActionContinuation.complete.pattern, }, ], element: "YieldActionContinuationExecution", @@ -108,7 +112,7 @@ const actionIdRoute = (actionRoute: string): RouteDescription => ({ ], element: actionRoute, index: false, - path: ":actionId", + path: widgetRoutes.activityAction.pattern, }); const ActionScopeGuard = function ActionScopeGuard() { diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx index 595831f4a..c32403bdc 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx +++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx @@ -28,7 +28,6 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { BorrowFlowSession, type BorrowTransactionFlowReview, - makeBorrowFlowNavigationState, } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; import { BorrowTransactionFlowExecutionPage, @@ -48,6 +47,11 @@ import { BorrowOperations } from "../../../src/services/api/operations"; import { ApiRequestError } from "../../../src/services/api/resource-sources"; import { WidgetConfigService } from "../../../src/services/config/widget-config"; import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation"; +import { + widgetRoutes, + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { createWidgetI18nInstance } from "../../../src/services/translation/widget-translation"; import { WalletOwnerMismatchError, @@ -385,15 +389,17 @@ const renderExecution = ( Effect.sync(() => { navigation.current?.(-1); }), - push: (path, options) => + push: (to) => Effect.sync(() => { - navigation.current?.(path, { state: options?.state }); + navigation.current?.(widgetTargetPath(to), { + state: widgetTargetState(to), + }); }), - replace: (path, options) => + replace: (to) => Effect.sync(() => { - navigation.current?.(path, { + navigation.current?.(widgetTargetPath(to), { replace: true, - state: options?.state, + state: widgetTargetState(to), }); }), }); @@ -444,7 +450,7 @@ const renderExecution = ( flowWallet.layer ); // Start hands its Session to the flow route through navigation state. - const flowState = makeBorrowFlowNavigationState(options.session ?? session); + const flowState = { borrowFlowSession: options.session ?? session }; const initialEntries = ( options.initialEntries ?? [options.initialPath ?? "/borrow/review"] ).map((pathname) => @@ -470,7 +476,7 @@ const renderExecution = ( } diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts index 7d3d7d73e..137b32b48 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts +++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts @@ -22,8 +22,8 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { BorrowFlowSession, type BorrowTransactionFlowIntake, - decodeBorrowFlowNavigationState, - makeBorrowFlowNavigationState, + borrowFlowRoutes, + getBorrowTransactionFlowDestination, } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; import { leaveBorrowFlow, @@ -35,11 +35,15 @@ import { BorrowOperations } from "../../../src/services/api/operations"; import { ApiRequestError } from "../../../src/services/api/resource-sources"; import { WidgetConfigService } from "../../../src/services/config/widget-config"; import { - toWidgetPath, type WidgetNavigation, type WidgetNavigationCommand, WidgetNavigationError, } from "../../../src/services/navigation/widget-navigation"; +import { + widgetRoutes, + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model"; import { type TransactionWorkflowInput, @@ -104,6 +108,13 @@ const marketPositionIntake: BorrowTransactionFlowIntake = { entry: { _tag: "MarketPosition", marketId }, }; +const borrowEntryDestination = getBorrowTransactionFlowDestination( + borrowEntryIntake.entry +); +const marketPositionDestination = getBorrowTransactionFlowDestination( + marketPositionIntake.entry +); + const makeAction = (status = "CREATED", id = "action-1"): Action => Schema.decodeUnknownSync(Action)({ action: "borrow", @@ -204,7 +215,12 @@ const makeKit = Effect.fn("test.makeBorrowFlowKit")(function* ( // compare routes only. const withoutNavigationState = ( commands: ReadonlyArray -) => commands.map(({ state: _state, ...command }) => command); +) => + commands.map((command) => + command._tag === "Back" + ? command + : { _tag: command._tag, path: widgetTargetPath(command.to) } + ); // The action id Steps' navigation state carries after a handoff. const handedOffActionId = (session: BorrowFlowSession) => @@ -238,20 +254,25 @@ describe("startBorrowTransactionFlow", () => { expect( (yield* kit.navigation.commands).map((command) => ({ _tag: command._tag, - path: command._tag === "Back" ? null : command.path, - session: Option.getOrNull( - decodeBorrowFlowNavigationState(command.state) - ), + path: command._tag === "Back" ? null : widgetTargetPath(command.to), + session: + command._tag === "Back" + ? null + : Option.getOrNull( + borrowFlowRoutes.BorrowEntry.state( + widgetTargetState(command.to) + ).pipe(Option.map((state) => state.borrowFlowSession)) + ), })) ).toEqual([ { _tag: "Push", - path: toWidgetPath("/borrow/review"), + path: borrowEntryDestination.reviewPath, session: first.session, }, { _tag: "Push", - path: toWidgetPath("/borrow/review"), + path: borrowEntryDestination.reviewPath, session: second.session, }, ]); @@ -294,7 +315,7 @@ describe("startBorrowTransactionFlow", () => { expect(withoutNavigationState(yield* kit.navigation.commands)).toEqual([ { _tag: "Push", - path: toWidgetPath(`/positions/borrow/${marketId}/review`), + path: marketPositionDestination.reviewPath, }, ]); expect(yield* kit.tracking.trackedEvents).toEqual([]); @@ -346,7 +367,7 @@ describe("startBorrowTransactionFlow", () => { expect(error).toMatchObject({ _tag: "WidgetNavigationError" }); expect(withoutNavigationState(yield* kit.navigation.commands)).toEqual([ - { _tag: "Push", path: toWidgetPath("/borrow/review") }, + { _tag: "Push", path: borrowEntryDestination.reviewPath }, ]); expect(yield* kit.tracking.trackedEvents).toEqual([]); }) @@ -417,8 +438,10 @@ describe("confirmBorrowAction", () => { expect(yield* kit.navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath("/borrow/steps"), - state: makeBorrowFlowNavigationState(handle, action.id), + to: borrowEntryDestination.steps({ + borrowFlowSession: handle, + executionActionId: action.id, + }), }, ]); }) @@ -435,11 +458,10 @@ describe("confirmBorrowAction", () => { expect(yield* kit.navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath(`/positions/borrow/${marketId}/steps`), - state: makeBorrowFlowNavigationState( - handle, - yield* handedOffActionId(handle) - ), + to: marketPositionDestination.steps({ + borrowFlowSession: handle, + executionActionId: yield* handedOffActionId(handle), + }), }, ]); }) @@ -859,7 +881,7 @@ describe("leaveBorrowFlow", () => { it.effect("replaces the flow with Borrow for a Borrow Entry flow", () => Effect.gen(function* () { expect(yield* leave(borrowEntryIntake)).toEqual([ - { _tag: "Replace", path: toWidgetPath("/borrow") }, + { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") }, ]); }) ); @@ -871,7 +893,7 @@ describe("leaveBorrowFlow", () => { expect(yield* leave(marketPositionIntake)).toEqual([ { _tag: "Replace", - path: toWidgetPath(`/positions/borrow/${marketId}`), + to: widgetRoutes.borrowPosition.build("Dashboard", { marketId }), }, ]); }) @@ -903,7 +925,7 @@ describe("leaveBorrowFlow", () => { expect(interruptedWhileLeaving).toBeUndefined(); expect(yield* kit.navigation.commands).toEqual([ - { _tag: "Replace", path: toWidgetPath("/borrow") }, + { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") }, ]); }) ); diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts index 41dd5f252..ce2db0814 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts +++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-pages.test.ts @@ -19,7 +19,7 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { BorrowFlowSession, type BorrowTransactionFlowIntake, - makeBorrowFlowNavigationState, + getBorrowTransactionFlowDestination, } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; import { emptyBorrowExecutionView } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-workflow"; import { makeBorrowFlowExecutionPage } from "../../../src/features/borrow-transaction-flow/state/execution-page"; @@ -30,7 +30,7 @@ import { } from "../../../src/features/borrow-transaction-flow/state/review-page"; import { BorrowOperations } from "../../../src/services/api/operations"; import { ApiRequestError } from "../../../src/services/api/resource-sources"; -import { toWidgetPath } from "../../../src/services/navigation/widget-navigation"; +import { widgetRoutes } from "../../../src/services/navigation/widget-routes"; import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model"; import type { TransactionWorkflowCommand } from "../../../src/services/transaction-workflow/transaction-workflow-model"; import { TransactionWorkflowService } from "../../../src/services/transaction-workflow/transaction-workflow-service"; @@ -202,8 +202,10 @@ describe("Borrow flow Review page", () => { expect(yield* navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath("/borrow/steps"), - state: makeBorrowFlowNavigationState(handle, action.id), + to: getBorrowTransactionFlowDestination(intake.entry).steps({ + borrowFlowSession: handle, + executionActionId: action.id, + }), }, ]); }) @@ -311,7 +313,7 @@ describe("Borrow flow Review page", () => { }); expect(yield* navigation.commands).toEqual([ - { _tag: "Replace", path: toWidgetPath("/borrow") }, + { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") }, ]); }) ); @@ -401,7 +403,7 @@ describe("Borrow flow Execution page", () => { }); expect(yield* navigation.commands).toEqual([ - { _tag: "Replace", path: toWidgetPath("/borrow") }, + { _tag: "Replace", to: widgetRoutes.borrow.build("Dashboard") }, ]); }) ); diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx index a3802cd60..e1c09e681 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx +++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-route-identity.dom.test.tsx @@ -16,7 +16,6 @@ import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { BorrowFlowSession, type BorrowTransactionFlowEntry, - makeBorrowFlowNavigationState, } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; import { BorrowTransactionFlowRoute, @@ -175,7 +174,7 @@ describe("Borrow Flow route", () => { const flow = await renderFlow({ entry: { pathname: "/positions/borrow/market-b/review", - state: makeBorrowFlowNavigationState(session), + state: { borrowFlowSession: session }, }, expected: "MarketPosition", }); @@ -198,7 +197,7 @@ describe("Borrow Flow route", () => { connectedWalletScope, entry: { pathname: "/positions/borrow/market-a/review", - state: makeBorrowFlowNavigationState(session), + state: { borrowFlowSession: session }, }, expected: "MarketPosition", }); @@ -213,7 +212,7 @@ describe("Borrow Flow route", () => { const flow = await renderFlow({ entry: { pathname: "/borrow/review", - state: makeBorrowFlowNavigationState(session), + state: { borrowFlowSession: session }, }, expected: "BorrowEntry", }); @@ -241,16 +240,13 @@ describe("Borrow Flow route", () => { const flow = await renderFlow({ entry: { pathname: "/borrow/review", - state: makeBorrowFlowNavigationState(first), + state: { borrowFlowSession: first }, }, expected: "BorrowEntry", }); await expect.poll(flow.page).toBe("Review:1"); - await flow.navigate( - "/borrow/review", - makeBorrowFlowNavigationState(second) - ); + await flow.navigate("/borrow/review", { borrowFlowSession: second }); await expect.poll(flow.page).toBe("Review:2"); await flow.navigate("/borrow/steps"); diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx index 1b92bb5f9..a95591380 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx +++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-routes.test.tsx @@ -2,6 +2,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { createRoutesFromElements, type RouteObject } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { createBorrowTransactionFlowRoutes } from "../../../src/features/borrow-transaction-flow/composition"; +import { widgetBorrowFlowRoutes } from "../../../src/services/navigation/widget-routes"; vi.mock( "../../../src/features/borrow-transaction-flow/react/borrow-flow-route", @@ -70,24 +71,26 @@ const describeRoute = (route: RouteObject): RouteContract => ({ path: route.path ?? null, }); -const flowPagesContract: ReadonlyArray = [ +const flowPagesContract = ( + entry: keyof typeof widgetBorrowFlowRoutes +): ReadonlyArray => [ { children: [], element: "BorrowReviewPage", - path: "review", + path: widgetBorrowFlowRoutes[entry].review.pattern, }, { children: [ { children: [], element: "BorrowTransactionFlowExecutionPage(Steps)>BorrowStepsPage", - path: "steps", + path: widgetBorrowFlowRoutes[entry].steps.pattern, }, { children: [], element: "BorrowTransactionFlowExecutionPage(Complete)>BorrowCompletePage", - path: "complete", + path: widgetBorrowFlowRoutes[entry].complete.pattern, }, ], element: "BorrowTransactionFlowExecutionRoute", @@ -107,7 +110,7 @@ describe("Borrow Transaction Flow routes", () => { expect(routes.map(describeRoute)).toEqual([ { - children: flowPagesContract, + children: flowPagesContract(entry), element: `BorrowTransactionFlowRoute(${entry})`, path: null, }, @@ -127,7 +130,7 @@ describe("Borrow Transaction Flow routes", () => { { children: [ { - children: flowPagesContract, + children: flowPagesContract("MarketPosition"), element: "BorrowFlowLayout", path: null, }, diff --git a/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx index 38f541716..3f79fbf7b 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx +++ b/packages/widget/tests/features/borrow-transaction-flow/transaction-flow-route-readiness.dom.test.tsx @@ -11,10 +11,7 @@ import { WalletAddress, } from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; -import { - BorrowFlowSession, - makeBorrowFlowNavigationState, -} from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; +import { BorrowFlowSession } from "../../../src/features/borrow-transaction-flow/model/borrow-transaction-flow"; import { BorrowTransactionFlowExecutionPage, BorrowTransactionFlowExecutionRoute, @@ -28,6 +25,10 @@ import { makeWidgetNavigation, WidgetNavigation, } from "../../../src/services/navigation/widget-navigation"; +import { + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model"; import { TransactionWorkflowInputError, @@ -157,20 +158,24 @@ const renderFlow = async ({ invalidInput = false, pathname }: FlowOptions) => { ], { initialEntries: [ - withUnserializedState( - { pathname }, - makeBorrowFlowNavigationState(session) - ), + withUnserializedState({ pathname }, { borrowFlowSession: session }), ], } ); const navigation = makeWidgetNavigation({ back: () => Effect.promise(() => router.navigate(-1)), - push: (path, options) => - Effect.promise(() => router.navigate(path, { state: options?.state })), - replace: (path, options) => + push: (to) => + Effect.promise(() => + router.navigate(widgetTargetPath(to), { + state: widgetTargetState(to), + }) + ), + replace: (to) => Effect.promise(() => - router.navigate(path, { replace: true, state: options?.state }) + router.navigate(widgetTargetPath(to), { + replace: true, + state: widgetTargetState(to), + }) ), }); const layer = Layer.mergeAll( diff --git a/packages/widget/tests/features/borrow/borrow-routes.test.tsx b/packages/widget/tests/features/borrow/borrow-routes.test.tsx index 89a138eb8..c73d5392d 100644 --- a/packages/widget/tests/features/borrow/borrow-routes.test.tsx +++ b/packages/widget/tests/features/borrow/borrow-routes.test.tsx @@ -7,6 +7,7 @@ import { } from "../../../src/features/borrow/composition"; import { createBorrowTransactionFlowRoutes } from "../../../src/features/borrow-transaction-flow/composition"; import { WalletScopeRouteGuard } from "../../../src/features/wallet/composition"; +import { widgetRoutes } from "../../../src/services/navigation/widget-routes"; const routeComposition = { WalletScopeRouteGuard, @@ -143,13 +144,13 @@ describe("Borrow routes", () => { ], element: "BorrowShell", index: false, - path: "borrow", + path: widgetRoutes.borrow.pattern, }, { children: [], - element: "Navigate", + element: "WidgetNavigate", index: false, - path: "borrow/*", + path: widgetRoutes.borrow.descendants, }, ]); }); @@ -176,7 +177,7 @@ describe("Borrow routes", () => { children: [], element: "BorrowPositionActionPage", index: false, - path: "action/:actionId", + path: widgetRoutes.borrowPositionAction.pattern, }, { children: [], @@ -187,7 +188,7 @@ describe("Borrow routes", () => { ], element: "BorrowPositionDetailsPage", index: false, - path: "borrow/:marketId", + path: widgetRoutes.borrowPosition.pattern, }, ], element: "BorrowConnectedWalletRoute", @@ -201,9 +202,9 @@ describe("Borrow routes", () => { }, { children: [], - element: "Navigate", + element: "WidgetNavigate", index: false, - path: "borrow/*", + path: widgetRoutes.positionsBorrow.descendants, }, ]); }); diff --git a/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx b/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx index baff35bb7..7c34b6785 100644 --- a/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx +++ b/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx @@ -5,7 +5,10 @@ import { act, type ReactNode } from "react"; import { describe, expect, it } from "vitest"; import { SKAtomRegistryProvider } from "../../../src/app/composition/providers/atom-runtime"; import { applicationRoutes } from "../../../src/app/routes/application-routes"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + WalletAddress, + YieldId, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { PositionDetailsWorkflowKey, @@ -22,7 +25,7 @@ const settings = { const positionWorkflowAtom = positionDetailsWorkflowAtom( new PositionDetailsWorkflowKey({ balanceId: "balance-1", - integrationId: "yield-1", + integrationId: Schema.decodeSync(YieldId)("yield-1"), pendingActionType: null, scope: new WalletScopeKey({ address: Schema.decodeSync(WalletAddress)("0xwallet-a"), diff --git a/packages/widget/tests/features/position-details/position-details-action-content.test.ts b/packages/widget/tests/features/position-details/position-details-action-content.test.ts index c2192e72c..c4e2fa424 100644 --- a/packages/widget/tests/features/position-details/position-details-action-content.test.ts +++ b/packages/widget/tests/features/position-details/position-details-action-content.test.ts @@ -3,7 +3,6 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { EarnBalance } from "../../../src/domain/earn/models"; import { - getPositionDetailsHubPath, positionDetailsExitHasContent, positionDetailsPendingHasContent, resolvePositionDetailsPageLayout, @@ -108,13 +107,4 @@ describe("position details action content", () => { }) ).toBe("InfoOnly"); }); - - it("builds the position hub path", () => { - expect( - getPositionDetailsHubPath({ - balanceId: "balance-1", - integrationId: "yield-1", - }) - ).toBe("/positions/yield-1/balance-1"); - }); }); diff --git a/packages/widget/tests/features/position-details/position-details-exit-command.test.ts b/packages/widget/tests/features/position-details/position-details-exit-command.test.ts index 8d2271542..8cbfe79e6 100644 --- a/packages/widget/tests/features/position-details/position-details-exit-command.test.ts +++ b/packages/widget/tests/features/position-details/position-details-exit-command.test.ts @@ -24,8 +24,8 @@ import { } from "../../../src/features/position-details/state/workflow"; import { walletConnectionStateAtom } from "../../../src/features/wallet/index"; import { - decodeYieldFlowNavigationState, type YieldTransactionFlowIntake, + yieldFlowRoutes, } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; import { YieldOpportunityKey, @@ -39,8 +39,12 @@ import { import { makeWidgetNavigation, type WidgetNavigationOptions, - type WidgetPath, } from "../../../src/services/navigation/widget-navigation"; +import { + type WidgetNavigationTarget, + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import type { TrackingService } from "../../../src/services/tracking/tracking-service"; import { disconnectedLedgerConnectorState, @@ -56,7 +60,10 @@ import { makeTestTracking } from "../../utils/services/tracking-service"; import { makeTestNavigation } from "../../utils/services/widget-navigation"; import { makeYieldFlowTestKit } from "../../utils/yield-flow-test-kit"; -type Push = (path: WidgetPath, options?: WidgetNavigationOptions) => void; +type Push = ( + to: WidgetNavigationTarget, + options?: WidgetNavigationOptions +) => void; /** The intake of the Session the single Start navigation carried to Review. */ const startedIntake = ( @@ -64,10 +71,14 @@ const startedIntake = ( reviewPath: string ): YieldTransactionFlowIntake => { expect(push).toHaveBeenCalledOnce(); - const [path, options] = push.mock.calls[0] ?? []; - expect(path).toBe(reviewPath); - return Option.getOrThrow(decodeYieldFlowNavigationState(options?.state)) - .intake; + const [to] = push.mock.calls[0] ?? []; + if (to === undefined) throw new Error("Expected a Review navigation"); + expect(widgetTargetPath(to)).toBe(reviewPath); + return Option.getOrThrow( + yieldFlowRoutes.Earn.state(widgetTargetState(to)).pipe( + Option.map((state) => state.yieldFlowSession.intake) + ) + ); }; const { diff --git a/packages/widget/tests/features/position-details/position-details-hub-model.test.ts b/packages/widget/tests/features/position-details/position-details-hub-model.test.ts index c6249b6fd..b5b6ae76f 100644 --- a/packages/widget/tests/features/position-details/position-details-hub-model.test.ts +++ b/packages/widget/tests/features/position-details/position-details-hub-model.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; import { - getPositionDetailsHubPath, positionDetailsExitHasContent, positionDetailsHubHasContent, resolvePositionDetailsActionCapabilities, @@ -11,15 +10,6 @@ import { import { yieldApiYieldFixture } from "../../fixtures"; describe("position details hub model", () => { - it("builds the bare position details hub path", () => { - expect( - getPositionDetailsHubPath({ - balanceId: "balance-1", - integrationId: "yield-1", - }) - ).toBe("/positions/yield-1/balance-1"); - }); - it("defaults to unstake when exit is supported, else stake", () => { expect( resolvePositionDetailsActionMode({ canStake: true, canUnstake: true }) diff --git a/packages/widget/tests/features/position-details/position-details-workflow.test.ts b/packages/widget/tests/features/position-details/position-details-workflow.test.ts index 99eea832b..2e34baa6c 100644 --- a/packages/widget/tests/features/position-details/position-details-workflow.test.ts +++ b/packages/widget/tests/features/position-details/position-details-workflow.test.ts @@ -2,7 +2,10 @@ import BigNumber from "bignumber.js"; import { Schema } from "effect"; import * as AtomRegistry from "effect/reactivity/AtomRegistry"; import { describe, expect, it } from "vitest"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + WalletAddress, + YieldId, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { makePositionDetailsWorkflowState, @@ -27,7 +30,7 @@ const scopeB = new WalletScopeKey({ const workflowAtom = positionDetailsWorkflowAtom( new PositionDetailsWorkflowKey({ balanceId: "balance-1", - integrationId: "yield-1", + integrationId: Schema.decodeSync(YieldId)("yield-1"), pendingActionType: null, scope: scopeA, }) @@ -74,7 +77,7 @@ describe("position details workflow atoms", () => { const walletBWorkflow = positionDetailsWorkflowAtom( new PositionDetailsWorkflowKey({ balanceId: "balance-1", - integrationId: "yield-1", + integrationId: Schema.decodeSync(YieldId)("yield-1"), pendingActionType: null, scope: scopeB, }) diff --git a/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx b/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx index 6b9757039..5234928d4 100644 --- a/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx +++ b/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx @@ -33,7 +33,7 @@ import { walletConnectionStateAtom, walletScopeAtom, } from "../../../src/features/wallet/index"; -import { decodeYieldFlowNavigationState } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; +import { yieldFlowRoutes } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; import { YieldOpportunityKey, yieldOpportunityAtom, @@ -45,6 +45,10 @@ import { import { WidgetConfigService } from "../../../src/services/config/widget-config"; import { ApplicationRouter } from "../../../src/services/navigation/application-router"; import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation"; +import { + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import type { NormalizedWalletState } from "../../../src/services/wallet/wallet-state"; import { yieldApiYieldFixture } from "../../fixtures"; import { makeConnectedWalletState } from "../../fixtures/wallet-state"; @@ -82,11 +86,18 @@ const navigationChannel: { } = { navigate: null }; const navigation = makeWidgetNavigation({ back: () => Effect.sync(() => navigationChannel.navigate?.(-1)), - push: (path, options) => - Effect.sync(() => navigationChannel.navigate?.(path, options)), - replace: (path, options) => + push: (to) => + Effect.sync(() => + navigationChannel.navigate?.(widgetTargetPath(to), { + state: widgetTargetState(to), + }) + ), + replace: (to) => Effect.sync(() => - navigationChannel.navigate?.(path, { ...options, replace: true }) + navigationChannel.navigate?.(widgetTargetPath(to), { + replace: true, + state: widgetTargetState(to), + }) ), }); const makeYieldFlowLayer = () => @@ -135,7 +146,9 @@ const PositionEntry = () => { }; const ReviewGuard = () => { - const session = decodeYieldFlowNavigationState(useLocation().state); + const session = yieldFlowRoutes.PositionStake.state(useLocation().state).pipe( + Option.map((state) => state.yieldFlowSession) + ); const carriesPositionStake = Option.exists( session, ({ intake, mount }) => diff --git a/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx b/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx index cb660c043..aadebfda6 100644 --- a/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx +++ b/packages/widget/tests/features/wallet/wallet-scope-route.dom.test.tsx @@ -7,6 +7,7 @@ import type { Connector } from "wagmi"; import { WalletAddress } from "../../../src/domain/identity/identifiers"; import { useWalletScopeRoute } from "../../../src/features/wallet/index"; import { WalletScopeRoute } from "../../../src/features/wallet/react/wallet-scope-route"; +import { widgetRoutes } from "../../../src/services/navigation/widget-routes"; import type { NormalizedWalletState } from "../../../src/services/wallet/wallet-state"; import { disconnectedNormalizedWalletState } from "../../../src/services/wallet/wallet-state"; import { render } from "../../utils/test-utils.dom.tsx"; @@ -79,7 +80,7 @@ const TestRouter = ({ } @@ -96,7 +97,10 @@ const TestRouter = ({ } /> - safe} /> + safe} + /> diff --git a/packages/widget/tests/features/yield-entry/yield-entry.test.ts b/packages/widget/tests/features/yield-entry/yield-entry.test.ts index 22f216056..e63b80341 100644 --- a/packages/widget/tests/features/yield-entry/yield-entry.test.ts +++ b/packages/widget/tests/features/yield-entry/yield-entry.test.ts @@ -16,14 +16,18 @@ import { makeYieldEntry } from "../../../src/features/yield-entry/index"; import { getYieldEntryCta } from "../../../src/features/yield-entry/model/yield-entry"; import type { YieldEntryFacadeInput } from "../../../src/features/yield-entry/state/yield-entry"; import { - decodeYieldFlowNavigationState, type YieldFlowSession, + yieldFlowRoutes, } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; import { makeWidgetNavigation, type WidgetNavigationOptions, - type WidgetPath, } from "../../../src/services/navigation/widget-navigation"; +import { + type WidgetNavigationTarget, + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { WalletModal } from "../../../src/services/wallet/wallet-modal"; import { disconnectedLedgerConnectorState, @@ -184,16 +188,21 @@ const readSubmitOutcome = ( ); type Push = ( - path: WidgetPath, + to: WidgetNavigationTarget, options?: WidgetNavigationOptions ) => Effect.Effect; /** The Session the single Start navigation carried to Review. */ const startedSession = (push: Mock): YieldFlowSession => { expect(push).toHaveBeenCalledOnce(); - const [path, options] = push.mock.calls[0] ?? []; - expect(path).toBe("/review"); - return Option.getOrThrow(decodeYieldFlowNavigationState(options?.state)); + const [to] = push.mock.calls[0] ?? []; + if (to === undefined) throw new Error("Expected a Review navigation"); + expect(widgetTargetPath(to)).toBe("/review"); + return Option.getOrThrow( + yieldFlowRoutes.Earn.state(widgetTargetState(to)).pipe( + Option.map((state) => state.yieldFlowSession) + ) + ); }; describe("Yield Entry", () => { @@ -485,7 +494,7 @@ describe("Yield Entry", () => { registry.set(facade.submitAtom, undefined); await readSubmitOutcome(registry, facade.submitAtom).toBe("submitted"); expect(ports.push).toHaveBeenCalledWith( - "/review", + expect.objectContaining({ path: "/review" }), expect.objectContaining({ _tag: "Push" }) ); expect(startedSession(ports.push)).toMatchObject({ diff --git a/packages/widget/tests/features/yield-flow-confidential.test.ts b/packages/widget/tests/features/yield-flow-confidential.test.ts index 3a0aad6ee..041c82cea 100644 --- a/packages/widget/tests/features/yield-flow-confidential.test.ts +++ b/packages/widget/tests/features/yield-flow-confidential.test.ts @@ -18,7 +18,7 @@ import { appRuntime } from "../../src/app/runtime/app-runtime"; import { walletRuntime } from "../../src/app/runtime/wallet-runtime"; import type { DirectTransaction } from "../../src/domain/action/direct-transaction"; import { ConfidentialHandle } from "../../src/domain/earn/confidential"; -import { WalletAddress } from "../../src/domain/identity/identifiers"; +import { WalletAddress, YieldId } from "../../src/domain/identity/identifiers"; import { Token } from "../../src/domain/token/token"; import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; import { @@ -108,7 +108,11 @@ const exitStart = ( unstakeToken: integration.token, walletScope, }, - mount: { _tag: "PositionExit", balanceId: "b", integrationId: "i" }, + mount: { + _tag: "PositionExit", + balanceId: "b", + integrationId: Schema.decodeSync(YieldId)("i"), + }, }; }; @@ -134,7 +138,11 @@ const withdrawUnwrapStart = (): NavigatingYieldTransactionFlowStart => { }, walletScope, }, - mount: { _tag: "PositionManage", balanceId: "b", integrationId: "i" }, + mount: { + _tag: "PositionManage", + balanceId: "b", + integrationId: Schema.decodeSync(YieldId)("i"), + }, }; }; diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx b/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx index 714a3af35..f92f19f33 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-navigation.dom.test.tsx @@ -38,6 +38,11 @@ import { import { WidgetConfigService } from "../../../src/services/config/widget-config"; import { ApplicationRouter } from "../../../src/services/navigation/application-router"; import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation"; +import { + widgetRoutes, + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model"; import { makeTransactionSignError, @@ -312,10 +317,13 @@ const FlowRoutes = ({ } /> + } > - }> + }> } /> }> { testNavigationChannel.navigate?.(-1); }), - push: (path, options) => + push: (to) => Effect.sync(() => { if (!testNavigationChannel.navigate) { throw new Error("Test navigation bridge is unavailable"); } - testNavigationChannel.navigate(path, { state: options?.state }); + testNavigationChannel.navigate(widgetTargetPath(to), { + state: widgetTargetState(to), + }); }), - replace: (path, options) => + replace: (to) => Effect.sync(() => { - testNavigationChannel.navigate?.(path, { + testNavigationChannel.navigate?.(widgetTargetPath(to), { replace: true, - state: options?.state, + state: widgetTargetState(to), }); }), }); diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts index 78fc3ebd6..4b25b89bb 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts @@ -15,18 +15,17 @@ import type { YieldAction } from "../../../src/domain/action/models"; import { TransactionWorkflowId, WalletAddress, + YieldId, } from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { - decodeYieldFlowExecutionHandoff, - decodeYieldFlowNavigationState, makeYieldActionContinuationSession, - makeYieldFlowNavigationState, makeYieldFlowSession, type NavigatingYieldTransactionFlowStart, YieldFlowHandoff, type YieldFlowSession, type YieldTransactionFlowIntake, + yieldFlowRoutes, } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; import { leaveYieldFlowExecution, @@ -43,11 +42,14 @@ import { InputValidationError, } from "../../../src/services/api/resource-sources"; import { - toWidgetPath, type WidgetNavigation, type WidgetNavigationCommand, WidgetNavigationError, } from "../../../src/services/navigation/widget-navigation"; +import { + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { initializeTransactionWorkflow } from "../../../src/services/transaction-workflow/internal/model"; import { type TransactionWorkflowInput, @@ -73,6 +75,7 @@ const address = Schema.decodeSync(WalletAddress)( "0x1234567890123456789012345678901234567890" ); const walletScope = new WalletScopeKey({ address, network: "ethereum" }); +const integrationId = Schema.decodeSync(YieldId)("integration"); const otherWalletScope = new WalletScopeKey({ address: Schema.decodeSync(WalletAddress)( "0x2234567890123456789012345678901234567890" @@ -173,7 +176,7 @@ const exitStart = (): NavigatingYieldTransactionFlowStart => ({ mount: { _tag: "PositionExit", balanceId: "balance", - integrationId: "integration", + integrationId, }, }); @@ -210,7 +213,12 @@ const makeKit = (overrides: KitOverrides = {}) => // compare routes only. const withoutNavigationState = ( commands: ReadonlyArray -) => commands.map(({ state: _state, ...command }) => command); +) => + commands.map((command) => + command._tag === "Back" + ? command + : { ...command, to: widgetTargetPath(command.to) } + ); const unavailablePreview = () => Effect.fail( @@ -258,29 +266,31 @@ describe("makeStartYieldTransactionFlow", () => { expect(second.session.intake.walletScope).not.toBe( input.intake.walletScope ); - expect(second.session.destination).toEqual({ - completePath: toWidgetPath("/complete"), - finishPath: toWidgetPath("/"), - reviewPath: toWidgetPath("/review"), - stepsPath: toWidgetPath("/steps"), + expect(second.session.destination).toMatchObject({ + completePath: "/complete", + finishPath: "/", + reviewPath: "/review", + stepsPath: "/steps", }); expect( (yield* kit.navigation.commands).map((command) => ({ _tag: command._tag, - path: command._tag === "Back" ? null : command.path, + path: command._tag === "Back" ? null : widgetTargetPath(command.to), session: Option.getOrNull( - decodeYieldFlowNavigationState(command.state) + yieldFlowRoutes.Earn.state( + command._tag === "Back" ? null : widgetTargetState(command.to) + ).pipe(Option.map((state) => state.yieldFlowSession)) ), })) ).toEqual([ { _tag: "Push", - path: toWidgetPath("/review"), + path: "/review", session: first.session, }, { _tag: "Push", - path: toWidgetPath("/review"), + path: "/review", session: second.session, }, ]); @@ -301,7 +311,7 @@ describe("makeStartYieldTransactionFlow", () => { expect(error).toMatchObject({ _tag: "WidgetNavigationError" }); expect(withoutNavigationState(yield* kit.navigation.commands)).toEqual([ - { _tag: "Push", path: toWidgetPath("/review") }, + { _tag: "Push", to: "/review" }, ]); }) ); @@ -540,13 +550,24 @@ describe("handOffYieldFlowSource", () => { expect(yield* kit.navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath("/steps"), - state: makeYieldFlowNavigationState(handle, handoff), + to: { + path: "/steps", + state: { executionHandoff: handoff, yieldFlowSession: handle }, + }, }, ]); + const [command] = yield* kit.navigation.commands; expect( - decodeYieldFlowExecutionHandoff( - (yield* kit.navigation.commands)[0]?.state + Option.getOrNull( + yieldFlowRoutes.Earn.state( + command && command._tag !== "Back" + ? widgetTargetState(command.to) + : null + ).pipe( + Option.flatMap((state) => + Option.fromUndefinedOr(state.executionHandoff) + ) + ) ) ).toBe(handoff); expect(yield* kit.tracking.trackedEvents).toEqual([]); @@ -579,8 +600,10 @@ describe("handOffYieldFlowSource", () => { expect(yield* kit.navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath("/positions/integration/balance/unstake/steps"), - state: makeYieldFlowNavigationState(handle, handoff), + to: { + path: "/positions/integration/balance/unstake/steps", + state: { executionHandoff: handoff, yieldFlowSession: handle }, + }, }, ]); }) @@ -823,8 +846,10 @@ describe("startYieldFlowExecution", () => { expect(yield* kit.navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath(`/activity/${action.id}/steps`), - state: makeYieldFlowNavigationState(handle, handoff), + to: { + path: `/activity/${action.id}/steps`, + state: { executionHandoff: handoff, yieldFlowSession: handle }, + }, }, ]); }) @@ -934,7 +959,7 @@ describe("leaveYieldFlowExecution", () => { Effect.gen(function* () { expect( yield* leave(makeYieldFlowSession(enterStart(), walletScope), "Review") - ).toEqual([{ _tag: "Replace", path: toWidgetPath("/review") }]); + ).toEqual([{ _tag: "Replace", to: "/review" }]); }) ); @@ -942,7 +967,7 @@ describe("leaveYieldFlowExecution", () => { Effect.gen(function* () { expect( yield* leave(makeYieldFlowSession(enterStart(), walletScope), "Exit") - ).toEqual([{ _tag: "Push", path: toWidgetPath("/") }]); + ).toEqual([{ _tag: "Push", to: "/" }]); }) ); @@ -955,7 +980,7 @@ describe("leaveYieldFlowExecution", () => { mount: { _tag: "PositionManage", balanceId: "balance", - integrationId: "integration", + integrationId, }, }), ], @@ -966,7 +991,7 @@ describe("leaveYieldFlowExecution", () => { mount: { _tag: "PositionStake", balanceId: "balance", - integrationId: "integration", + integrationId, }, }), ], @@ -974,7 +999,7 @@ describe("leaveYieldFlowExecution", () => { Effect.gen(function* () { expect( yield* leave(makeYieldFlowSession(start(), walletScope), "Exit") - ).toEqual([{ _tag: "Push", path: toWidgetPath("/positions") }]); + ).toEqual([{ _tag: "Push", to: "/positions" }]); }) ); @@ -985,7 +1010,7 @@ describe("leaveYieldFlowExecution", () => { makeYieldActionContinuationSession(makeContinuationIntake()), "Exit" ) - ).toEqual([{ _tag: "Push", path: toWidgetPath("/activity") }]); + ).toEqual([{ _tag: "Push", to: "/activity" }]); }) ); @@ -1018,7 +1043,7 @@ describe("leaveYieldFlowExecution", () => { expect(interruptedWhileBackPending).toBeUndefined(); expect(yield* kit.navigation.commands).toEqual([ - { _tag: "Replace", path: toWidgetPath("/review") }, + { _tag: "Replace", to: "/review" }, ]); }) ); diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts index b579f0aba..b245d6989 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts @@ -6,11 +6,13 @@ import * as Reactivity from "effect/reactivity/Reactivity"; import { appRuntime } from "../../../src/app/runtime/app-runtime"; import { walletRuntime } from "../../../src/app/runtime/wallet-runtime"; import { TokenBalance } from "../../../src/domain/finance/models"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + WalletAddress, + YieldId, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { makeYieldActionContinuationSession, - makeYieldFlowNavigationState, makeYieldFlowSession, YieldFlowHandoff, type YieldFlowSession, @@ -28,7 +30,6 @@ import { LegacyResourceSource, YieldResourceSource, } from "../../../src/services/api/resource-sources"; -import { toWidgetPath } from "../../../src/services/navigation/widget-navigation"; import { resourceInvalidationKeys } from "../../../src/services/resource-invalidation"; import { yieldApiActionFixture, @@ -42,6 +43,7 @@ const address = Schema.decodeSync(WalletAddress)( "0x1234567890123456789012345678901234567890" ); const walletScope = new WalletScopeKey({ address, network: "ethereum" }); +const integrationId = Schema.decodeSync(YieldId)("integration"); const selectedStake = yieldApiYieldFixture(); const gasToken = selectedStake.mechanics.gasFeeToken; const enterIntake: YieldTransactionFlowIntake = { @@ -254,7 +256,7 @@ describe("Yield flow Review page", () => { mount: { _tag: "PositionExit", balanceId: "balance", - integrationId: "integration", + integrationId, }, }, walletScope @@ -312,8 +314,10 @@ describe("Yield flow Review page", () => { expect(yield* kit.navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath("/steps"), - state: makeYieldFlowNavigationState(handle, handoff), + to: { + path: "/steps", + state: { executionHandoff: handoff, yieldFlowSession: handle }, + }, }, ]); @@ -469,8 +473,13 @@ describe("Yield flow Review page", () => { expect(yield* kit.navigation.commands).toEqual([ { _tag: "Push", - path: toWidgetPath(`/activity/${action.id}/steps`), - state: makeYieldFlowNavigationState(handle, held?.handoff), + to: { + path: `/activity/${action.id}/steps`, + state: { + executionHandoff: held?.handoff, + yieldFlowSession: handle, + }, + }, }, ]); }) diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx b/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx index 125a31a8f..b3f55bd95 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx @@ -11,11 +11,13 @@ import { useNavigate, } from "react-router"; import { describe, expect, it } from "vitest"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + WalletAddress, + YieldId, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { walletScopeAtom } from "../../../src/features/wallet/index"; import { - makeYieldFlowNavigationState, makeYieldFlowSession, type YieldFlowSession, } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; @@ -80,7 +82,7 @@ const makeExitSession = () => { mount: { _tag: "PositionExit", balanceId: "balance", - integrationId: "yield", + integrationId: Schema.decodeSync(YieldId)("yield"), }, }, walletScope @@ -140,7 +142,7 @@ const renderFlow = async ( }} /> - }> + }> } @@ -178,7 +180,7 @@ describe("Yield Flow route", () => { it("redirects home when the navigated Session has another intake variant", async () => { const flow = await renderFlow({ pathname: "/review", - state: makeYieldFlowNavigationState(makeExitSession()), + state: { yieldFlowSession: makeExitSession() }, }); await expect.poll(flow.location).toBe("/"); @@ -189,7 +191,7 @@ describe("Yield Flow route", () => { const flow = await renderFlow( { pathname: "/review", - state: makeYieldFlowNavigationState(makeEnterSession()), + state: { yieldFlowSession: makeEnterSession() }, }, otherWalletScope ); @@ -202,7 +204,7 @@ describe("Yield Flow route", () => { const session = makeEnterSession(); const flow = await renderFlow({ pathname: "/review", - state: makeYieldFlowNavigationState(session), + state: { yieldFlowSession: session }, }); await expect.poll(flow.page).toBe("Review:1"); @@ -227,11 +229,11 @@ describe("Yield Flow route", () => { const second = makeEnterSession(); const flow = await renderFlow({ pathname: "/review", - state: makeYieldFlowNavigationState(first), + state: { yieldFlowSession: first }, }); await expect.poll(flow.page).toBe("Review:1"); - await flow.navigate("/review", makeYieldFlowNavigationState(second)); + await flow.navigate("/review", { yieldFlowSession: second }); await expect.poll(flow.page).toBe("Review:2"); await flow.navigate("/steps"); diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx b/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx index 21dde5821..749566019 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-routes.test.tsx @@ -2,6 +2,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react"; import { createRoutesFromElements, type RouteObject } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { createYieldFlowRoutes } from "../../../src/features/yield-transaction-flow/composition"; +import { widgetYieldFlowRoutes } from "../../../src/services/navigation/widget-routes"; vi.mock( "../../../src/features/yield-transaction-flow/react/yield-flow-route", @@ -133,11 +134,13 @@ const getRouteContract = ( const standardRouteContract = ({ completePage, journey, + mount, reviewPage, stepsPage, }: { readonly completePage: string; readonly journey: "Enter" | "Exit" | "Manage"; + readonly mount: "Earn" | "PositionExit" | "PositionManage" | "PositionStake"; readonly reviewPage: string; readonly stepsPage: string; }): ReadonlyArray => [ @@ -147,7 +150,7 @@ const standardRouteContract = ({ children: [], element: reviewPage, index: false, - path: "review", + path: widgetYieldFlowRoutes[mount].review.pattern, }, { children: [ @@ -155,13 +158,13 @@ const standardRouteContract = ({ children: [], element: `YieldFlowExecutionPage>${stepsPage}`, index: false, - path: "steps", + path: widgetYieldFlowRoutes[mount].steps.pattern, }, { children: [], element: `YieldFlowExecutionPage>${completePage}`, index: false, - path: "complete", + path: widgetYieldFlowRoutes[mount].complete.pattern, }, ], element: "YieldFlowExecutionRoute", @@ -178,6 +181,7 @@ const standardRouteContract = ({ describe("Yield Transaction Flow routes", () => { it.each([ [ + "Earn", "Enter", { completePage: "StakeCompletePage", @@ -186,6 +190,16 @@ describe("Yield Transaction Flow routes", () => { }, ], [ + "PositionStake", + "Enter", + { + completePage: "StakeCompletePage", + reviewPage: "StakeReviewPage", + stepsPage: "StakeStepsPage", + }, + ], + [ + "PositionExit", "Exit", { completePage: "UnstakeCompletePage", @@ -194,6 +208,7 @@ describe("Yield Transaction Flow routes", () => { }, ], [ + "PositionManage", "Manage", { completePage: "PendingCompletePage", @@ -201,9 +216,12 @@ describe("Yield Transaction Flow routes", () => { stepsPage: "PendingStepsPage", }, ], - ] as const)("publishes the %s journey", (journey, pages) => { - expect(getRouteContract(createYieldFlowRoutes({ journey }))).toEqual( - standardRouteContract({ journey, ...pages }) - ); - }); + ] as const)( + "publishes the %s mount's %s journey", + (mount, journey, pages) => { + expect(getRouteContract(createYieldFlowRoutes({ mount }))).toEqual( + standardRouteContract({ journey, mount, ...pages }) + ); + } + ); }); diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts index 375ef8384..0186f49a1 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts @@ -6,7 +6,10 @@ import * as AtomRegistry from "effect/reactivity/AtomRegistry"; import { describe, expect, it, vi } from "vitest"; import { appRuntime } from "../../../src/app/runtime/app-runtime"; import { walletRuntime } from "../../../src/app/runtime/wallet-runtime"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + WalletAddress, + YieldId, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { walletScopeAtom } from "../../../src/features/wallet/index"; import { @@ -14,15 +17,16 @@ import { startYieldTransactionFlowAtom, } from "../../../src/features/yield-transaction-flow/index"; import { - decodeYieldFlowNavigationState, type NavigatingYieldTransactionFlowStart, type YieldTransactionFlowIntake, + yieldFlowRoutes, } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; +import { makeWidgetNavigation } from "../../../src/services/navigation/widget-navigation"; import { - makeWidgetNavigation, - type WidgetNavigationOptions, - type WidgetPath, -} from "../../../src/services/navigation/widget-navigation"; + type WidgetNavigationTarget, + widgetTargetPath, + widgetTargetState, +} from "../../../src/services/navigation/widget-routes"; import { disconnectedLedgerConnectorState, disconnectedNormalizedWalletState, @@ -111,12 +115,12 @@ const makeManageIntake = (): Intake<"Manage"> => { }; const makeRegistry = ( - push: (path: WidgetPath, options?: WidgetNavigationOptions) => void, + push: (to: WidgetNavigationTarget) => void, currentWalletScope: WalletScopeKey = walletScope ) => { const navigation = makeWidgetNavigation({ back: () => Effect.void, - push: (path, options) => Effect.sync(() => push(path, options)), + push: (to) => Effect.sync(() => push(to)), replace: () => Effect.void, }); const walletState = { @@ -190,7 +194,7 @@ const startCases: ReadonlyArray< mount: { _tag: "PositionStake", balanceId: "balance", - integrationId: "yield", + integrationId: Schema.decodeSync(YieldId)("yield"), }, }), name: "position Stake", @@ -205,7 +209,7 @@ const startCases: ReadonlyArray< mount: { _tag: "PositionExit", balanceId: "balance", - integrationId: "yield", + integrationId: Schema.decodeSync(YieldId)("yield"), }, }), name: "position Exit", @@ -220,7 +224,7 @@ const startCases: ReadonlyArray< mount: { _tag: "PositionManage", balanceId: "balance", - integrationId: "yield", + integrationId: Schema.decodeSync(YieldId)("yield"), }, }), name: "position Manage", @@ -258,8 +262,7 @@ describe("Yield Transaction Flow interface", () => { reviewPath, stepsPath, }) => { - const push = - vi.fn<(path: WidgetPath, options?: WidgetNavigationOptions) => void>(); + const push = vi.fn<(to: WidgetNavigationTarget) => void>(); const registry = makeRegistry(push); const command = makeCommand(); @@ -270,14 +273,17 @@ describe("Yield Transaction Flow interface", () => { .poll(() => readStartOutcome(registry)?._tag) .toBe("Started"); expect(push).toHaveBeenCalledOnce(); - const [path, options] = push.mock.calls[0] ?? []; - expect(path).toBe(reviewPath); + const [to] = push.mock.calls[0] ?? []; + if (to === undefined) throw new Error("Expected a Review navigation"); + expect(widgetTargetPath(to)).toBe(reviewPath); const session = Option.getOrThrow( - decodeYieldFlowNavigationState(options?.state) + yieldFlowRoutes.Earn.state(widgetTargetState(to)).pipe( + Option.map((state) => state.yieldFlowSession) + ) ); expect(session.intake).toEqual(command.intake); expect(session.mount).toEqual(command.mount); - expect(session.destination).toEqual({ + expect(session.destination).toMatchObject({ completePath, finishPath, reviewPath, @@ -325,16 +331,21 @@ describe("Yield Flow route group", () => { ["/activity/action-1/steps", "/activity/action-1"], ["/activity/action-1/complete", "/activity/action-1"], ["/activity/action-2/steps", "/activity/action-2"], - ["/", null], - ["/positions", null], - ["/positions/yield/balance", null], - ["/activity", null], - ["/activity/", null], - ["/borrow/review", null], ] as const)("groups %s as %s", (pathname, group) => { expect(getYieldFlowRouteGroup(pathname)).toBe(group); }); + it.each([ + "/", + "/positions", + "/positions/yield/balance", + "/activity", + "/activity/", + "/borrow/review", + ])("does not group %s", (pathname) => { + expect(getYieldFlowRouteGroup(pathname)).toBeNull(); + }); + it("shares a group across one mount's flow routes and separates other mounts", () => { const groupsOf = (paths: ReadonlyArray) => new Set(paths.map(getYieldFlowRouteGroup)); @@ -344,6 +355,7 @@ describe("Yield Flow route group", () => { "/positions/yield/balance/unstake", "/positions/yield/balance/pending-action", "/positions/yield/other/stake", + "/positions/yield/other/pending-action", ]; for (const base of mounts) { @@ -354,5 +366,8 @@ describe("Yield Flow route group", () => { expect(groupsOf(mounts.map((base) => `${base}/review`)).size).toBe( mounts.length ); + expect( + groupsOf(["/activity/action-1/steps", "/activity/action-2/steps"]).size + ).toBe(2); }); }); diff --git a/packages/widget/tests/services/navigation/widget-navigation.test.ts b/packages/widget/tests/services/navigation/widget-navigation.test.ts index 4b38c141a..efb14157e 100644 --- a/packages/widget/tests/services/navigation/widget-navigation.test.ts +++ b/packages/widget/tests/services/navigation/widget-navigation.test.ts @@ -1,12 +1,9 @@ import { describe, expect, it, vi } from "@effect/vitest"; -import { Effect, Layer, Schema, Stream } from "effect"; +import { Effect, Layer, Stream } from "effect"; import type { DataRouter } from "react-router"; import { ApplicationRouter } from "../../../src/services/navigation/application-router"; -import { - toWidgetPath, - WidgetNavigation, - WidgetPath, -} from "../../../src/services/navigation/widget-navigation"; +import { WidgetNavigation } from "../../../src/services/navigation/widget-navigation"; +import { widgetRoutes } from "../../../src/services/navigation/widget-routes"; describe("WidgetNavigation", () => { it.effect("executes absolute push, replace, back, and scroll decisions", () => @@ -20,10 +17,10 @@ describe("WidgetNavigation", () => { const { router } = yield* ApplicationRouter; const navigation = yield* WidgetNavigation; - yield* navigation.push(toWidgetPath("/positions/one"), { + yield* navigation.push(widgetRoutes.positions.build(), { scroll: "reset", }); - yield* navigation.replace(toWidgetPath("/positions/two"), { + yield* navigation.replace(widgetRoutes.activity.build(), { scroll: "preserve", }); yield* navigation.back({ scroll: "reset" }); @@ -51,18 +48,12 @@ describe("WidgetNavigation", () => { }) ); - it("rejects route-relative application paths", () => { - expect(() => Schema.decodeUnknownSync(WidgetPath)("positions/one")).toThrow( - "Expected a string matching template literal parts" - ); - }); - it.effect("normalizes router failures", () => Effect.gen(function* () { const applicationRouterLayer = Layer.succeed( ApplicationRouter, ApplicationRouter.of({ - pathnames: Stream.never, + locations: Stream.never, router: { navigate: () => Promise.reject(new Error("router rejected navigation")), @@ -70,7 +61,7 @@ describe("WidgetNavigation", () => { }) ); const program = WidgetNavigation.use((navigation) => - navigation.push(toWidgetPath("/review"), { scroll: "preserve" }) + navigation.push(widgetRoutes.earn.build(), { scroll: "preserve" }) ).pipe( Effect.provide( WidgetNavigation.layer().pipe(Layer.provide(applicationRouterLayer)) diff --git a/packages/widget/tests/services/navigation/widget-routes.test.ts b/packages/widget/tests/services/navigation/widget-routes.test.ts new file mode 100644 index 000000000..facc593c7 --- /dev/null +++ b/packages/widget/tests/services/navigation/widget-routes.test.ts @@ -0,0 +1,69 @@ +import { Option, Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { YieldId } from "../../../src/domain/identity/identifiers"; +import { + isWidgetPath, + widgetRoutes, + widgetYieldFlowRoutes, +} from "../../../src/services/navigation/widget-routes"; + +describe("widget route registry", () => { + it("round-trips params that need encoding", () => { + const params = { + balanceId: "a/b c?%", + integrationId: Schema.decodeSync(YieldId)("eth-lido#1"), + }; + const path = widgetRoutes.positionDetails.build(params); + + expect(path).toBe("/positions/eth-lido%231/a%2Fb%20c%3F%25"); + expect(widgetRoutes.positionDetails.match(path)).toEqual( + Option.some(params) + ); + }); + + it("does not match malformed encodings or branded ids that fail decoding", () => { + expect( + Option.isNone(widgetRoutes.positionDetails.match("/positions/%E0/b")) + ).toBe(true); + expect( + Option.isNone( + widgetRoutes.activityAction.match(`/activity/${encodeURIComponent("")}`) + ) + ).toBe(true); + }); + + it("matches exactly, tolerating a trailing slash, or as a section prefix", () => { + const route = widgetRoutes.positions; + + expect(Option.isSome(route.match("/positions/"))).toBe(true); + expect(Option.isNone(route.match("/positions/yield-1/balance-1"))).toBe( + true + ); + expect( + Option.isSome(route.matchPrefix("/positions/yield-1/balance-1")) + ).toBe(true); + expect(Option.isNone(route.matchPrefix("/positionsx"))).toBe(true); + expect(isWidgetPath("/positions/", route.build())).toBe(true); + }); + + it("keys a flow mount's Review, Steps, and Complete alike", () => { + const flow = widgetYieldFlowRoutes.PositionExit; + const params = { + balanceId: "balance-1", + integrationId: Schema.decodeSync(YieldId)("yield-1"), + }; + const { completePath, reviewPath, stepsPath } = flow.at(params); + const other = flow.at({ ...params, balanceId: "balance-2" }); + + const key = flow.mountKey(reviewPath); + expect(key).toEqual( + Option.some(widgetRoutes.positionUnstake.build(params)) + ); + expect(flow.mountKey(stepsPath)).toEqual(key); + expect(flow.mountKey(completePath)).toEqual(key); + expect(flow.mountKey(other.reviewPath)).not.toEqual(key); + expect( + Option.isNone(flow.mountKey(widgetRoutes.positionUnstake.build(params))) + ).toBe(true); + }); +}); diff --git a/packages/widget/tests/utils/services/widget-navigation.test.ts b/packages/widget/tests/utils/services/widget-navigation.test.ts index b8d387392..159acd1b7 100644 --- a/packages/widget/tests/utils/services/widget-navigation.test.ts +++ b/packages/widget/tests/utils/services/widget-navigation.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Ref } from "effect"; import { - toWidgetPath, type WidgetNavigationCommand, WidgetNavigationError, } from "../../../src/services/navigation/widget-navigation"; +import { widgetRoutes } from "../../../src/services/navigation/widget-routes"; import { makeTestNavigation } from "./widget-navigation"; describe("makeTestNavigation", () => { @@ -14,16 +14,16 @@ describe("makeTestNavigation", () => { Effect.gen(function* () { const navigation = yield* makeTestNavigation(); - yield* navigation.service.push(toWidgetPath("/earn")); - yield* navigation.service.replace(toWidgetPath("/activity"), { + yield* navigation.service.push(widgetRoutes.earn.build()); + yield* navigation.service.replace(widgetRoutes.activity.build(), { scroll: "preserve", }); - yield* navigation.service.back({ state: { source: "test" } }); + yield* navigation.service.back({ scroll: "preserve" }); expect(yield* navigation.commands).toEqual([ - { _tag: "Push", path: "/earn" }, - { _tag: "Replace", path: "/activity", scroll: "preserve" }, - { _tag: "Back", state: { source: "test" } }, + { _tag: "Push", to: "/" }, + { _tag: "Replace", scroll: "preserve", to: "/activity" }, + { _tag: "Back", scroll: "preserve" }, ]); yield* navigation.clear; @@ -41,13 +41,13 @@ describe("makeTestNavigation", () => { }); const error = yield* Effect.flip( - navigation.service.push(toWidgetPath("/earn")) + navigation.service.push(widgetRoutes.earn.build()) ); expect(error).toBe(failure); expect(yield* Ref.get(received)).toEqual({ _tag: "Push", - path: "/earn", + to: "/", }); }) ); diff --git a/packages/widget/tests/utils/services/widget-navigation.ts b/packages/widget/tests/utils/services/widget-navigation.ts index 7c70564d9..a60afe153 100644 --- a/packages/widget/tests/utils/services/widget-navigation.ts +++ b/packages/widget/tests/utils/services/widget-navigation.ts @@ -24,10 +24,10 @@ export const makeTestNavigation = Effect.fn("makeTestNavigation")(function* ( const service = makeWidgetNavigation({ back: (navigationOptions) => execute({ ...navigationOptions, _tag: "Back" }), - push: (path, navigationOptions) => - execute({ ...navigationOptions, _tag: "Push", path }), - replace: (path, navigationOptions) => - execute({ ...navigationOptions, _tag: "Replace", path }), + push: (to, navigationOptions) => + execute({ ...navigationOptions, _tag: "Push", to }), + replace: (to, navigationOptions) => + execute({ ...navigationOptions, _tag: "Replace", to }), }); return { diff --git a/tools/ast-grep/rule-tests/no-raw-widget-path-test.yml b/tools/ast-grep/rule-tests/no-raw-widget-path-test.yml new file mode 100644 index 000000000..303af7a5a --- /dev/null +++ b/tools/ast-grep/rule-tests/no-raw-widget-path-test.yml @@ -0,0 +1,34 @@ +id: no-raw-widget-path +valid: + - 'navigate(widgetRoutes.activity.build())' + - 'navigation.push(widgetRoutes.positions.build())' + - 'router.navigate(-1)' + - 'const match = useWidgetRouteMatch(widgetRoutes.positionDetails)' + - 'const element = ' + - 'const element = ' + - 'const element = ' + - 'const element = ' + - 'const element = } />' + - 'const element = } />' + - 'const element = } />' + - 'const element = ' + - 'const hex = value.startsWith("0x")' + - 'const scoped = account.startsWith(`${chainId}:`)' + - 'const root = segment === "/"' + - 'const endpoint = HttpClientRequest.get("/v1/yields")' +invalid: + - 'navigate("/activity")' + - 'navigate(`action/${action.id}`)' + - 'router.navigate("/positions", { replace: true })' + - 'const match = useMatch("/activity/:actionId/steps")' + - 'const match = matchPath("positions/:integrationId/:balanceId", pathname)' + - 'const element = ' + - 'const element = ' + - 'const element = Positions' + - 'const element = Market' + - 'const element = } />' + - 'const element = {children}' + - 'const activity = location.pathname.startsWith("/activity")' + - 'const borrow = Match.when(startsWith("/borrow"), () => "borrow")' + - 'const earn = location.pathname === "/"' + - 'const other = "/positions" !== pathname' diff --git a/tools/ast-grep/rules/no-raw-widget-path.yml b/tools/ast-grep/rules/no-raw-widget-path.yml new file mode 100644 index 000000000..19a40d228 --- /dev/null +++ b/tools/ast-grep/rules/no-raw-widget-path.yml @@ -0,0 +1,123 @@ +id: no-raw-widget-path +language: Tsx +severity: error +message: Build Widget paths, route patterns, and route matchers from the route registry (`services/navigation/widget-routes.ts`) instead of string literals. Only relative `..` redirects and `*` catch-alls may stay literal. +files: + - packages/widget/src/**/*.ts + - packages/widget/src/**/*.tsx +ignores: + - packages/widget/src/generated/** + - packages/widget/src/services/navigation/widget-routes.ts +utils: + raw-path: + any: + - kind: template_string + - kind: string + not: + regex: ^["'](?:\.\.(?:/\.\.)*|\*)["']$ + absolute-path-literal: + any: + - kind: template_string + regex: ^`/ + - kind: string + regex: ^["']/ + raw-path-value: + any: + - matches: raw-path + - kind: jsx_expression + has: + matches: raw-path + first-argument-raw-path: + kind: arguments + has: + nthChild: 1 + matches: raw-path + router-element: + any: + - kind: jsx_opening_element + - kind: jsx_self_closing_element +rule: + any: + # navigate("/…"), router.navigate("/…") + - kind: call_expression + all: + - has: + field: function + any: + - kind: identifier + regex: ^navigate$ + - kind: member_expression + has: + field: property + regex: ^navigate$ + - has: + field: arguments + matches: first-argument-raw-path + # useMatch("…"), matchPath("…") + - kind: call_expression + all: + - has: + field: function + regex: ^(?:useMatch|matchPath)$ + - has: + field: arguments + matches: first-argument-raw-path + # , + - kind: jsx_attribute + all: + - has: + kind: property_identifier + regex: ^to$ + - has: + matches: raw-path-value + - inside: + matches: router-element + has: + field: name + regex: ^(?:Navigate|Link|NavLink|SKLink|WidgetNavigate|WidgetLink)$ + # + - kind: jsx_attribute + all: + - has: + kind: property_identifier + regex: ^path$ + - has: + matches: raw-path-value + - inside: + matches: router-element + has: + field: name + regex: ^Route$ + # pathname.startsWith("/…"), startsWith("/…") + - kind: call_expression + all: + - has: + field: function + any: + - kind: identifier + regex: ^startsWith$ + - kind: member_expression + has: + field: property + regex: ^startsWith$ + - has: + field: arguments + has: + nthChild: 1 + matches: absolute-path-literal + # pathname === "/…" + - kind: binary_expression + all: + - has: + field: operator + regex: ^[!=]==?$ + - has: + matches: absolute-path-literal + - has: + any: + - kind: identifier + regex: "[Pp]athname$" + - kind: member_expression + has: + field: property + regex: ^pathname$