diff --git a/apps/ledger-live-desktop/package.json b/apps/ledger-live-desktop/package.json index e22acd8fb27..eea4a456051 100644 --- a/apps/ledger-live-desktop/package.json +++ b/apps/ledger-live-desktop/package.json @@ -74,6 +74,7 @@ "@ledgerhq/hw-transport-http": "workspace:^", "@ledgerhq/hw-transport-vault": "workspace:^", "@ledgerhq/client-ids": "workspace:*", + "@ledgerhq/crypto-banner": "workspace:*", "@ledgerhq/lumen-design-core": "catalog:", "@ledgerhq/lumen-ui-react": "0.0.58", "@ledgerhq/ledger-key-ring-protocol": "workspace:^", diff --git a/apps/ledger-live-desktop/src/newArch/features/MarketPerformanceWidget/index.tsx b/apps/ledger-live-desktop/src/newArch/features/MarketPerformanceWidget/index.tsx index fd49dd25786..4307952b97a 100644 --- a/apps/ledger-live-desktop/src/newArch/features/MarketPerformanceWidget/index.tsx +++ b/apps/ledger-live-desktop/src/newArch/features/MarketPerformanceWidget/index.tsx @@ -1,14 +1,13 @@ import React from "react"; import { ABTestingVariants } from "@ledgerhq/types-live"; -import { MarketPerformanceWidgetContainer } from "./components/Container"; -import { useMarketPerformanceWidget } from "./useMarketPerformanceWidget"; +import { CryptoBanner } from "@ledgerhq/crypto-banner"; type Props = { variant: ABTestingVariants; }; const MarketPerformanceWidget = ({ variant }: Props) => { - return ; + return ; }; export default React.memo(MarketPerformanceWidget); diff --git a/apps/ledger-live-desktop/src/renderer/reducers/index.ts b/apps/ledger-live-desktop/src/renderer/reducers/index.ts index e992b9a9813..7ca6332425a 100644 --- a/apps/ledger-live-desktop/src/renderer/reducers/index.ts +++ b/apps/ledger-live-desktop/src/renderer/reducers/index.ts @@ -21,12 +21,20 @@ import sendFlow, { SendFlowState } from "./sendFlow"; import onboarding, { OnboardingState } from "./onboarding"; import { lldRTKApiReducers, LLDRTKApiState } from "./rtkQueryApi"; import { identitiesSlice, IdentitiesState } from "@ledgerhq/client-ids/store"; +import { cryptoBannerReducer } from "@ledgerhq/crypto-banner"; import type { PayloadAction, UnknownAction } from "@reduxjs/toolkit"; +interface CryptoBannerState { + isEnabled: boolean; + autoScroll: boolean; + scrollSpeed: number; +} + export type State = LLDRTKApiState & { accounts: AccountsState; application: ApplicationState; countervalues: CountervaluesState; + cryptoBanner: CryptoBannerState; devices: DevicesState; dynamicContent: DynamicContentState; identities: IdentitiesState; @@ -47,6 +55,7 @@ const appReducer = combineReducers({ accounts, application, countervalues, + cryptoBanner: cryptoBannerReducer, devices, dynamicContent, identities: identitiesSlice.reducer, diff --git a/apps/ledger-live-desktop/src/renderer/reducers/rtkQueryApi.ts b/apps/ledger-live-desktop/src/renderer/reducers/rtkQueryApi.ts index e7c35d6dfb6..05f262a2774 100644 --- a/apps/ledger-live-desktop/src/renderer/reducers/rtkQueryApi.ts +++ b/apps/ledger-live-desktop/src/renderer/reducers/rtkQueryApi.ts @@ -3,6 +3,7 @@ import { ofacGeoBlockApi } from "@ledgerhq/live-common/api/ofacGeoBlockApi"; import { assetsDataApi } from "@ledgerhq/live-common/dada-client/state-manager/api"; import { cryptoAssetsApi } from "@ledgerhq/cryptoassets/cal-client/state-manager/api"; import { pushDevicesApi } from "@ledgerhq/client-ids/api"; +import { cryptoBannerApi } from "@ledgerhq/crypto-banner"; // Add new RTK Query API here: const APIs = { @@ -10,6 +11,7 @@ const APIs = { [cryptoAssetsApi.reducerPath]: cryptoAssetsApi, [ofacGeoBlockApi.reducerPath]: ofacGeoBlockApi, [pushDevicesApi.reducerPath]: pushDevicesApi, + [cryptoBannerApi.reducerPath]: cryptoBannerApi, }; /* diff --git a/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx index dc23d7ee881..e15b8a49f78 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/dashboard/index.tsx @@ -29,6 +29,7 @@ import AnalyticsOptInPrompt from "LLD/features/AnalyticsOptInPrompt/screens"; import { useDisplayOnPortfolioAnalytics } from "LLD/features/AnalyticsOptInPrompt/hooks/useDisplayOnPortfolio"; import SwapWebViewEmbedded from "./components/SwapWebViewEmbedded"; import BannerSection from "./components/BannerSection"; +import { CryptoBanner } from "@ledgerhq/crypto-banner"; // This forces only one visible top banner at a time export const TopBannerContainer = styled.div` @@ -65,9 +66,6 @@ export default function DashboardPage() { [shouldFilterTokenOpsZeroAmount], ); - const { enabled: marketPerformanceEnabled, variant: marketPerformanceVariant } = - useMarketPerformanceFeatureFlag(); - const { isFeatureFlagsAnalyticsPrefDisplayed, analyticsOptInPromptProps } = useDisplayOnPortfolioAnalytics(); @@ -89,52 +87,7 @@ export default function DashboardPage() { ) : totalAccounts > 0 ? ( <> - {ptxSwapLiveAppOnPortfolio?.enabled ? ( - - - - - - - - - - - ) : marketPerformanceEnabled ? ( - - - - - - - - ) : ( - - )} - - - {totalOperations > 0 && ( - - )} + ) : ( diff --git a/apps/ledger-live-desktop/src/state-manager/cryptoBanner.example.ts b/apps/ledger-live-desktop/src/state-manager/cryptoBanner.example.ts new file mode 100644 index 00000000000..c850ece287a --- /dev/null +++ b/apps/ledger-live-desktop/src/state-manager/cryptoBanner.example.ts @@ -0,0 +1,12 @@ +import { configureStore } from "@reduxjs/toolkit"; +import { cryptoBannerApi, cryptoBannerReducer } from "@ledgerhq/crypto-banner"; + +export const configureCryptoBannerStore = () => { + return configureStore({ + reducer: { + cryptoBanner: cryptoBannerReducer, + [cryptoBannerApi.reducerPath]: cryptoBannerApi.reducer, + }, + middleware: getDefaultMiddleware => getDefaultMiddleware().concat(cryptoBannerApi.middleware), + }); +}; diff --git a/apps/ledger-live-mobile/package.json b/apps/ledger-live-mobile/package.json index ca795069991..48613d39c1f 100644 --- a/apps/ledger-live-mobile/package.json +++ b/apps/ledger-live-mobile/package.json @@ -100,6 +100,7 @@ "@ledgerhq/hw-transport": "workspace:*", "@ledgerhq/hw-transport-http": "workspace:^", "@ledgerhq/client-ids": "workspace:^", + "@ledgerhq/crypto-banner": "workspace:*", "@ledgerhq/icons-ui": "workspace:^", "@ledgerhq/ledger-key-ring-protocol": "workspace:^", "@ledgerhq/live-config": "workspace:^", diff --git a/apps/ledger-live-mobile/src/components/CryptoBannerIntegration.example.tsx b/apps/ledger-live-mobile/src/components/CryptoBannerIntegration.example.tsx new file mode 100644 index 00000000000..1356e640c1e --- /dev/null +++ b/apps/ledger-live-mobile/src/components/CryptoBannerIntegration.example.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { View, StyleSheet, SafeAreaView } from "react-native"; +import { CryptoBanner } from "@ledgerhq/crypto-banner"; + +export const AppWithCryptoBanner = () => { + return ( + + + + {/* Rest of your application */} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#000", + }, + content: { + flex: 1, + }, +}); diff --git a/apps/ledger-live-mobile/src/context/rtkQueryApi.ts b/apps/ledger-live-mobile/src/context/rtkQueryApi.ts index 7b5518031c1..2c26b1813a1 100644 --- a/apps/ledger-live-mobile/src/context/rtkQueryApi.ts +++ b/apps/ledger-live-mobile/src/context/rtkQueryApi.ts @@ -4,11 +4,13 @@ import { assetsDataApi } from "@ledgerhq/live-common/dada-client/state-manager/a import { cryptoAssetsApi } from "@ledgerhq/cryptoassets/cal-client/state-manager/api"; import { firebaseRemoteConfigApi } from "LLM/api/firebaseRemoteConfigApi"; import { pushDevicesApi } from "@ledgerhq/client-ids/api"; +import { cryptoBannerApi } from "@ledgerhq/crypto-banner"; // Add new RTK Query API here: const APIs = { [assetsDataApi.reducerPath]: assetsDataApi, [cryptoAssetsApi.reducerPath]: cryptoAssetsApi, + [cryptoBannerApi.reducerPath]: cryptoBannerApi, [firebaseRemoteConfigApi.reducerPath]: firebaseRemoteConfigApi, [ofacGeoBlockApi.reducerPath]: ofacGeoBlockApi, [pushDevicesApi.reducerPath]: pushDevicesApi, diff --git a/apps/ledger-live-mobile/src/reducers/index.ts b/apps/ledger-live-mobile/src/reducers/index.ts index 4acde51def8..cd3e9c6b610 100644 --- a/apps/ledger-live-mobile/src/reducers/index.ts +++ b/apps/ledger-live-mobile/src/reducers/index.ts @@ -25,6 +25,7 @@ import wallet from "./wallet"; import walletconnect from "./walletconnect"; import walletSync from "./walletSync"; import { identitiesSlice } from "@ledgerhq/client-ids/store"; +import { cryptoBannerReducer } from "@ledgerhq/crypto-banner"; import type { UnknownAction } from "@reduxjs/toolkit"; export type AppStore = Store; @@ -35,6 +36,7 @@ const appReducer = combineReducers({ auth, ble, countervalues, + cryptoBanner: cryptoBannerReducer, dynamicContent, earn, identities: identitiesSlice.reducer, diff --git a/apps/ledger-live-mobile/src/reducers/types.ts b/apps/ledger-live-mobile/src/reducers/types.ts index 5047d92b889..a6db080ffed 100644 --- a/apps/ledger-live-mobile/src/reducers/types.ts +++ b/apps/ledger-live-mobile/src/reducers/types.ts @@ -379,12 +379,19 @@ export type LargeMoverState = { // === ROOT STATE === +interface CryptoBannerState { + isEnabled: boolean; + autoScroll: boolean; + scrollSpeed: number; +} + export type State = LLMRTKApiState & { accounts: AccountsState; appstate: AppState; auth: AuthState; ble: BleState; countervalues: CountervaluesState; + cryptoBanner: CryptoBannerState; dynamicContent: DynamicContentState; earn: EarnState; identities: IdentitiesState; diff --git a/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx b/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx index ca8574fa93b..1445a6d8037 100644 --- a/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx +++ b/apps/ledger-live-mobile/src/screens/Portfolio/index.tsx @@ -54,6 +54,7 @@ import { getAccountCurrency } from "@ledgerhq/live-common/account/index"; import { PORTFOLIO_VIEW_ID, TOP_CHAINS } from "~/utils/constants"; import { buildFeatureFlagTags } from "~/utils/datadogUtils"; import { renderItem } from "LLM/utils/renderItem"; +import { CryptoBanner } from "@ledgerhq/crypto-banner"; type NavigationProps = BaseComposite< StackNavigatorProps @@ -242,14 +243,15 @@ function PortfolioScreen({ navigation }: NavigationProps) { <> - - + {/* } keyExtractor={(_: unknown, index: number) => String(index)} showsVerticalScrollIndicator={false} testID={showAssets ? "PortfolioAccountsList" : "PortfolioEmptyList"} - /> + /> */} + { + return configureStore({ + reducer: { + cryptoBanner: cryptoBannerReducer, + [cryptoBannerApi.reducerPath]: cryptoBannerApi.reducer, + }, + middleware: getDefaultMiddleware => getDefaultMiddleware().concat(cryptoBannerApi.middleware), + }); +}; diff --git a/features/cryptoBanner/.eslintrc.json b/features/cryptoBanner/.eslintrc.json new file mode 100644 index 00000000000..cf22841392d --- /dev/null +++ b/features/cryptoBanner/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "extends": "../../.eslintrc.json", + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["@apps/*"], + "message": "Features should not import from apps" + } + ] + } + ] + } +} diff --git a/features/cryptoBanner/.gitignore b/features/cryptoBanner/.gitignore new file mode 100644 index 00000000000..cb1ad66f656 --- /dev/null +++ b/features/cryptoBanner/.gitignore @@ -0,0 +1,9 @@ +lib/ +lib-es/ +dist/ +node_modules/ +coverage/ +*.log +.DS_Store +.turbo/ + diff --git a/features/cryptoBanner/__integrations__/cryptoBanner.integration.test.tsx b/features/cryptoBanner/__integrations__/cryptoBanner.integration.test.tsx new file mode 100644 index 00000000000..506f0f1a118 --- /dev/null +++ b/features/cryptoBanner/__integrations__/cryptoBanner.integration.test.tsx @@ -0,0 +1,109 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { rest } from "msw"; +import { setupServer } from "msw/node"; +import { CryptoBanner } from "../components/CryptoBanner"; +import { cryptoBannerApi } from "../data-layer/api/cryptoBanner.api"; +import { cryptoBannerReducer } from "../data-layer/entities/cryptoBanner/cryptoBannerSlice"; + +const mockTopCryptosResponse = { + cryptoAssets: { + bitcoin: { id: "bitcoin", ticker: "BTC", name: "Bitcoin" }, + ethereum: { id: "ethereum", ticker: "ETH", name: "Ethereum" }, + solana: { id: "solana", ticker: "SOL", name: "Solana" }, + cardano: { id: "cardano", ticker: "ADA", name: "Cardano" }, + polkadot: { id: "polkadot", ticker: "DOT", name: "Polkadot" }, + }, + markets: { + bitcoin: { price: 45000, priceChangePercentage24h: 2.5, marketCapRank: 1 }, + ethereum: { price: 3000, priceChangePercentage24h: -1.2, marketCapRank: 2 }, + solana: { price: 100, priceChangePercentage24h: 5.3, marketCapRank: 3 }, + cardano: { price: 0.5, priceChangePercentage24h: -0.8, marketCapRank: 4 }, + polkadot: { price: 7.5, priceChangePercentage24h: 1.5, marketCapRank: 5 }, + }, + currenciesOrder: { + metaCurrencyIds: ["bitcoin", "ethereum", "solana", "cardano", "polkadot"], + }, +}; + +const server = setupServer( + rest.get("https://dada.api.ledger.com/v1/assets", (req, res, ctx) => { + return res(ctx.json(mockTopCryptosResponse)); + }), +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +const createTestStore = () => { + return configureStore({ + reducer: { + cryptoBanner: cryptoBannerReducer, + [cryptoBannerApi.reducerPath]: cryptoBannerApi.reducer, + }, + middleware: getDefaultMiddleware => getDefaultMiddleware().concat(cryptoBannerApi.middleware), + }); +}; + +describe("CryptoBanner Integration", () => { + it("should fetch and display top 5 cryptocurrencies", async () => { + const store = createTestStore(); + + render( + + + , + ); + + expect(screen.getByText(/Loading market data/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText("BTC")).toBeInTheDocument(); + }); + + expect(screen.getByText("ETH")).toBeInTheDocument(); + expect(screen.getByText("SOL")).toBeInTheDocument(); + expect(screen.getByText("ADA")).toBeInTheDocument(); + expect(screen.getByText("DOT")).toBeInTheDocument(); + }); + + it("should display prices and percentage changes", async () => { + const store = createTestStore(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText("$45,000.00")).toBeInTheDocument(); + }); + + expect(screen.getByText("+2.50%")).toBeInTheDocument(); + expect(screen.getByText("-1.20%")).toBeInTheDocument(); + }); + + it("should handle API errors gracefully", async () => { + server.use( + rest.get("https://dada.api.ledger.com/v1/assets", (req, res, ctx) => { + return res(ctx.status(500)); + }), + ); + + const store = createTestStore(); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(/Unable to load market data/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/features/cryptoBanner/jest.config.js b/features/cryptoBanner/jest.config.js new file mode 100644 index 00000000000..dd03c2362a4 --- /dev/null +++ b/features/cryptoBanner/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + preset: "ts-jest", + testEnvironment: "jsdom", + moduleFileExtensions: ["ts", "tsx", "js", "jsx"], + testMatch: ["**/__tests__/**/*.(spec|test).(ts|tsx)", "**/*.(spec|test).(ts|tsx)"], + transform: { + "^.+\\.(ts|tsx)$": "ts-jest", + }, + moduleNameMapper: { + "^@ledgerhq/crypto-banner$": "/src/index.ts", + "^@ledgerhq/crypto-banner/(.*)$": "/src/$1", + }, + collectCoverageFrom: [ + "src/**/*.{ts,tsx}", + "!src/**/*.test.{ts,tsx}", + "!src/**/__integrations__/**", + ], +}; diff --git a/features/cryptoBanner/package.json b/features/cryptoBanner/package.json new file mode 100644 index 00000000000..681b5ca3b26 --- /dev/null +++ b/features/cryptoBanner/package.json @@ -0,0 +1,90 @@ +{ + "name": "@ledgerhq/crypto-banner", + "version": "1.0.0", + "description": "Bloomberg-style cryptocurrency ticker banner feature", + "keywords": [ + "ledger", + "crypto", + "banner", + "ticker", + "feature" + ], + "repository": { + "type": "git", + "url": "https://github.com/LedgerHQ/ledger-live.git" + }, + "bugs": { + "url": "https://github.com/LedgerHQ/ledger-live/issues" + }, + "homepage": "https://github.com/LedgerHQ/ledger-live/tree/develop/features/cryptoBanner", + "publishConfig": { + "access": "public" + }, + "main": "./dist/web/index.js", + "module": "./dist/web/index.mjs", + "types": "./dist/web/index.d.ts", + "react-native": "./dist/native/index.native.js", + "exports": { + ".": { + "react-native": "./dist/native/index.native.js", + "import": "./dist/web/index.mjs", + "require": "./dist/web/index.js", + "types": "./dist/web/index.d.ts" + } + }, + "license": "Apache-2.0", + "scripts": { + "clean": "rimraf lib lib-es dist", + "build": "tsup", + "build:web": "tsup --filter web", + "build:native": "tsup --filter native", + "prewatch": "pnpm build", + "watch": "tsup --watch", + "watch:web": "tsup --watch --filter web", + "watch:native": "tsup --watch --filter native", + "lint": "eslint . --no-error-on-unmatched-pattern --ext .ts,.tsx --cache", + "lint:fix": "pnpm lint --fix", + "test": "jest", + "test:jest": "jest", + "typecheck": "tsc --noEmit", + "unimported": "pnpm knip --directory ../.. -W features/cryptoBanner" + }, + "files": [ + "lib", + "lib-es", + "src" + ], + "dependencies": { + "@reduxjs/toolkit": "catalog:", + "@ledgerhq/live-env": "workspace:*" + }, + "devDependencies": { + "@testing-library/react": "^14.0.0", + "@types/jest": "^29.5.10", + "@types/react": "^18.3.0", + "jest": "^29.7.0", + "msw": "catalog:", + "react": "catalog:", + "react-redux": "catalog:", + "rimraf": "^4.4.1", + "ts-jest": "^29.1.1", + "tsup": "^8.0.0", + "typescript": "^5.4.3" + }, + "peerDependencies": { + "react": ">=18", + "react-redux": ">=9", + "react-native": ">=0.65.1" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "react-redux": { + "optional": true + } + } +} diff --git a/features/cryptoBanner/src/components/CryptoBanner/CryptoBannerView.native.tsx b/features/cryptoBanner/src/components/CryptoBanner/CryptoBannerView.native.tsx new file mode 100644 index 00000000000..9211e239fb5 --- /dev/null +++ b/features/cryptoBanner/src/components/CryptoBanner/CryptoBannerView.native.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useRef } from "react"; +import { View, Text, ScrollView, StyleSheet, Animated, Easing } from "react-native"; +import { TopCrypto } from "../../data-layer/api/types"; + +interface CryptoBannerViewProps { + topCryptos: TopCrypto[]; + isEnabled: boolean; + autoScroll: boolean; + scrollSpeed: number; + isLoading: boolean; + error: unknown; + onToggleBanner: () => void; + onToggleAutoScroll: () => void; + onRefresh: () => void; +} + +export const CryptoBannerView = ({ + topCryptos, + isEnabled, + autoScroll, + scrollSpeed, + isLoading, + error, +}: CryptoBannerViewProps) => { + const scrollX = useRef(new Animated.Value(0)).current; + const scrollViewRef = useRef(null); + + useEffect(() => { + if (!autoScroll) return; + + const animation = Animated.loop( + Animated.timing(scrollX, { + toValue: -1000, + duration: scrollSpeed * 1000, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + + animation.start(); + + return () => { + animation.stop(); + }; + }, [autoScroll, scrollSpeed, scrollX]); + + if (!isEnabled) return null; + + if (isLoading) { + return ( + + Loading market data... + + ); + } + + if (error) { + return ( + + Unable to load market data + + ); + } + + const cryptoItems = [...topCryptos, ...topCryptos]; + + return ( + + + + {cryptoItems.map((crypto, index) => ( + + {crypto.ticker} + {crypto.price && ( + + $ + {crypto.price.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + )} + {crypto.priceChangePercentage24h !== undefined && ( + = 0 ? styles.positive : styles.negative, + ]} + > + {crypto.priceChangePercentage24h >= 0 ? "+" : ""} + {crypto.priceChangePercentage24h.toFixed(2)}% + + )} + + ))} + + + + ); +}; + +const styles = StyleSheet.create({ + banner: { + width: "100%", + backgroundColor: "blue", + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: "#333", + }, + scrollContent: { + paddingHorizontal: 16, + }, + content: { + flexDirection: "row", + gap: 48, + }, + cryptoItem: { + flexDirection: "row", + alignItems: "center", + gap: 12, + marginRight: 48, + }, + ticker: { + fontWeight: "bold", + fontSize: 14, + color: "#60a5fa", + fontFamily: "Courier New", + }, + price: { + fontSize: 14, + color: "#e5e5e5", + fontFamily: "Courier New", + }, + change: { + fontSize: 12, + fontWeight: "600", + fontFamily: "Courier New", + }, + positive: { + color: "#10b981", + }, + negative: { + color: "#ef4444", + }, + loadingText: { + textAlign: "center", + color: "#e5e5e5", + fontSize: 14, + }, + errorBanner: { + backgroundColor: "#ef4444", + }, + errorText: { + textAlign: "center", + color: "#ffffff", + fontSize: 14, + }, +}); diff --git a/features/cryptoBanner/src/components/CryptoBanner/CryptoBannerView.web.tsx b/features/cryptoBanner/src/components/CryptoBanner/CryptoBannerView.web.tsx new file mode 100644 index 00000000000..bf634492500 --- /dev/null +++ b/features/cryptoBanner/src/components/CryptoBanner/CryptoBannerView.web.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef } from "react"; +import { TopCrypto } from "../../data-layer/api/types"; + +interface CryptoBannerViewProps { + topCryptos: TopCrypto[]; + isEnabled: boolean; + autoScroll: boolean; + scrollSpeed: number; + isLoading: boolean; + error: unknown; + onToggleBanner: () => void; + onToggleAutoScroll: () => void; + onRefresh: () => void; + className?: string; +} + +export const CryptoBannerView = ({ + topCryptos, + isEnabled, + autoScroll, + scrollSpeed, + isLoading, + error, + className = "", +}: CryptoBannerViewProps) => { + const scrollRef = useRef(null); + + useEffect(() => { + if (!autoScroll || !scrollRef.current) return; + + const element = scrollRef.current; + let position = 0; + + const scroll = () => { + position += 1; + if (position >= element.scrollWidth / 2) { + position = 0; + } + element.scrollLeft = position; + }; + + const interval = setInterval(scroll, scrollSpeed); + return () => clearInterval(interval); + }, [autoScroll, scrollSpeed]); + + if (!isEnabled) return null; + + if (isLoading) { + return ( +
+
Loading market data...
+
+ ); + } + + if (error) { + return ( +
+
Unable to load market data
+
+ ); + } + + const cryptoItems = [...topCryptos, ...topCryptos]; + + return ( +
+
+
+ {cryptoItems.map((crypto, index) => ( +
+ {crypto.ticker} + {crypto.price && ( + + $ + {crypto.price.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + )} + {crypto.priceChangePercentage24h !== undefined && ( + = 0 ? "positive" : "negative"}`} + > + {crypto.priceChangePercentage24h >= 0 ? "+" : ""} + {crypto.priceChangePercentage24h.toFixed(2)}% + + )} +
+ ))} +
+
+ +
+ ); +}; diff --git a/features/cryptoBanner/src/components/CryptoBanner/index.native.tsx b/features/cryptoBanner/src/components/CryptoBanner/index.native.tsx new file mode 100644 index 00000000000..3d72cfad80d --- /dev/null +++ b/features/cryptoBanner/src/components/CryptoBanner/index.native.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { useCryptoBannerViewModel } from "./useCryptoBannerViewModel"; +import { GetTopCryptosParams } from "../../data-layer/api/types"; +import { CryptoBannerView } from "./CryptoBannerView.native"; + +interface CryptoBannerProps extends GetTopCryptosParams { + className?: string; +} + +export const CryptoBanner = (props: CryptoBannerProps) => { + const viewModel = useCryptoBannerViewModel({ + product: props.product, + version: props.version, + isStaging: props.isStaging, + }); + + return ; +}; diff --git a/features/cryptoBanner/src/components/CryptoBanner/index.tsx b/features/cryptoBanner/src/components/CryptoBanner/index.tsx new file mode 100644 index 00000000000..40091cafdf6 --- /dev/null +++ b/features/cryptoBanner/src/components/CryptoBanner/index.tsx @@ -0,0 +1,5 @@ +// Entry point - exports platform-specific implementation +// Desktop/Web bundlers will use index.web.tsx +// React Native bundlers will use index.native.tsx (via package.json "react-native" field) + +export * from "./index.web"; diff --git a/features/cryptoBanner/src/components/CryptoBanner/index.web.tsx b/features/cryptoBanner/src/components/CryptoBanner/index.web.tsx new file mode 100644 index 00000000000..19d95fd0ffd --- /dev/null +++ b/features/cryptoBanner/src/components/CryptoBanner/index.web.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { useCryptoBannerViewModel } from "./useCryptoBannerViewModel"; +import { GetTopCryptosParams } from "../../data-layer/api/types"; +import { CryptoBannerView } from "./CryptoBannerView.web"; + +interface CryptoBannerProps extends GetTopCryptosParams { + className?: string; +} + +export const CryptoBanner = (props: CryptoBannerProps) => { + const viewModel = useCryptoBannerViewModel({ + product: props.product, + version: props.version, + isStaging: props.isStaging, + }); + + return ; +}; diff --git a/features/cryptoBanner/src/components/CryptoBanner/useCryptoBannerViewModel.ts b/features/cryptoBanner/src/components/CryptoBanner/useCryptoBannerViewModel.ts new file mode 100644 index 00000000000..f099a6b60b8 --- /dev/null +++ b/features/cryptoBanner/src/components/CryptoBanner/useCryptoBannerViewModel.ts @@ -0,0 +1,41 @@ +import { useSelector, useDispatch } from "react-redux"; +import { useCryptoBanner } from "../../hooks/useCryptoBanner"; +import { + selectIsEnabled, + selectAutoScroll, + selectScrollSpeed, +} from "../../data-layer/entities/cryptoBanner/cryptoBannerSelectors"; +import { + toggleBanner, + setAutoScroll, +} from "../../data-layer/entities/cryptoBanner/cryptoBannerSlice"; +import { GetTopCryptosParams } from "../../data-layer/api/types"; + +export const useCryptoBannerViewModel = (params: GetTopCryptosParams) => { + const dispatch = useDispatch(); + const isEnabled = useSelector(selectIsEnabled); + const autoScroll = useSelector(selectAutoScroll); + const scrollSpeed = useSelector(selectScrollSpeed); + + const { topCryptos, isLoading, error, refetch } = useCryptoBanner(params); + + const handleToggleBanner = () => { + dispatch(toggleBanner()); + }; + + const handleToggleAutoScroll = () => { + dispatch(setAutoScroll(!autoScroll)); + }; + + return { + topCryptos, + isEnabled, + autoScroll, + scrollSpeed, + isLoading, + error, + onToggleBanner: handleToggleBanner, + onToggleAutoScroll: handleToggleAutoScroll, + onRefresh: refetch, + }; +}; diff --git a/features/cryptoBanner/src/data-layer/api/cryptoBanner.api.ts b/features/cryptoBanner/src/data-layer/api/cryptoBanner.api.ts new file mode 100644 index 00000000000..71d287b29eb --- /dev/null +++ b/features/cryptoBanner/src/data-layer/api/cryptoBanner.api.ts @@ -0,0 +1,70 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { getEnv } from "@ledgerhq/live-env"; +import { AssetsAdditionalData, GetTopCryptosParams, TopCrypto } from "./types"; + +export enum CryptoBannerTags { + TopCryptos = "TopCryptos", +} + +interface RawApiResponse { + cryptoAssets: Record; + markets: Record< + string, + { + price?: number; + priceChangePercentage24h?: number; + marketCapRank?: number; + } + >; + currenciesOrder: { + metaCurrencyIds: string[]; + }; +} + +export const cryptoBannerApi = createApi({ + reducerPath: "cryptoBannerApi", + baseQuery: fetchBaseQuery({ + baseUrl: "", + }), + tagTypes: [CryptoBannerTags.TopCryptos], + endpoints: build => ({ + getTopCryptos: build.query({ + query: ({ product, version, isStaging = false }: GetTopCryptosParams) => { + const baseUrl = isStaging ? getEnv("DADA_API_STAGING") : getEnv("DADA_API_PROD"); + const params = { + pageSize: 5, + product, + minVersion: version, + additionalData: [AssetsAdditionalData.MarketTrend], + }; + + return { + url: `${baseUrl}/assets`, + params, + }; + }, + providesTags: [CryptoBannerTags.TopCryptos], + transformResponse: (response: RawApiResponse): TopCrypto[] => { + const data = response; + + const topCryptos = data.currenciesOrder.metaCurrencyIds.slice(0, 5).map(id => { + const asset = data.cryptoAssets[id]; + const market = data.markets[id]; + + return { + id: asset.id, + ticker: asset.ticker, + name: asset.name, + price: market?.price, + priceChangePercentage24h: market?.priceChangePercentage24h, + marketCapRank: market?.marketCapRank, + }; + }); + + return topCryptos; + }, + }), + }), +}); + +export const { useGetTopCryptosQuery } = cryptoBannerApi; diff --git a/features/cryptoBanner/src/data-layer/api/types.ts b/features/cryptoBanner/src/data-layer/api/types.ts new file mode 100644 index 00000000000..81f0f6c3a11 --- /dev/null +++ b/features/cryptoBanner/src/data-layer/api/types.ts @@ -0,0 +1,19 @@ +export enum AssetsAdditionalData { + Apy = "apy", + MarketTrend = "marketTrend", +} + +export interface TopCrypto { + id: string; + ticker: string; + name: string; + price?: number; + priceChangePercentage24h?: number; + marketCapRank?: number; +} + +export interface GetTopCryptosParams { + product: "llm" | "lld"; + version: string; + isStaging?: boolean; +} diff --git a/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSelectors.ts b/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSelectors.ts new file mode 100644 index 00000000000..807c43fd24d --- /dev/null +++ b/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSelectors.ts @@ -0,0 +1,5 @@ +import { RootState } from "../../../types/state"; + +export const selectIsEnabled = (state: RootState) => state.cryptoBanner.isEnabled; +export const selectAutoScroll = (state: RootState) => state.cryptoBanner.autoScroll; +export const selectScrollSpeed = (state: RootState) => state.cryptoBanner.scrollSpeed; diff --git a/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSlice.test.ts b/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSlice.test.ts new file mode 100644 index 00000000000..248a7a85692 --- /dev/null +++ b/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSlice.test.ts @@ -0,0 +1,33 @@ +import { + cryptoBannerReducer, + toggleBanner, + setAutoScroll, + setScrollSpeed, +} from "./cryptoBannerSlice"; + +describe("cryptoBannerSlice", () => { + const initialState = { + isEnabled: true, + autoScroll: true, + scrollSpeed: 50, + }; + + it("should return the initial state", () => { + expect(cryptoBannerReducer(undefined, { type: "unknown" })).toEqual(initialState); + }); + + it("should toggle banner", () => { + const actual = cryptoBannerReducer(initialState, toggleBanner()); + expect(actual.isEnabled).toEqual(false); + }); + + it("should set auto scroll", () => { + const actual = cryptoBannerReducer(initialState, setAutoScroll(false)); + expect(actual.autoScroll).toEqual(false); + }); + + it("should set scroll speed", () => { + const actual = cryptoBannerReducer(initialState, setScrollSpeed(100)); + expect(actual.scrollSpeed).toEqual(100); + }); +}); diff --git a/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSlice.ts b/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSlice.ts new file mode 100644 index 00000000000..a43822c90d9 --- /dev/null +++ b/features/cryptoBanner/src/data-layer/entities/cryptoBanner/cryptoBannerSlice.ts @@ -0,0 +1,32 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; + +interface CryptoBannerState { + isEnabled: boolean; + autoScroll: boolean; + scrollSpeed: number; +} + +const initialState: CryptoBannerState = { + isEnabled: true, + autoScroll: true, + scrollSpeed: 50, +}; + +export const cryptoBannerSlice = createSlice({ + name: "cryptoBanner", + initialState, + reducers: { + toggleBanner: state => { + state.isEnabled = !state.isEnabled; + }, + setAutoScroll: (state, action: PayloadAction) => { + state.autoScroll = action.payload; + }, + setScrollSpeed: (state, action: PayloadAction) => { + state.scrollSpeed = action.payload; + }, + }, +}); + +export const { toggleBanner, setAutoScroll, setScrollSpeed } = cryptoBannerSlice.actions; +export const cryptoBannerReducer = cryptoBannerSlice.reducer; diff --git a/features/cryptoBanner/src/hooks/useCryptoBanner.test.ts b/features/cryptoBanner/src/hooks/useCryptoBanner.test.ts new file mode 100644 index 00000000000..cf22ee2c8ec --- /dev/null +++ b/features/cryptoBanner/src/hooks/useCryptoBanner.test.ts @@ -0,0 +1,33 @@ +import React from "react"; +import { renderHook } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { configureStore } from "@reduxjs/toolkit"; +import { useCryptoBanner } from "./useCryptoBanner"; +import { cryptoBannerApi } from "../data-layer/api/cryptoBanner.api"; + +const createWrapper = () => { + const store = configureStore({ + reducer: { + [cryptoBannerApi.reducerPath]: cryptoBannerApi.reducer, + }, + middleware: getDefaultMiddleware => getDefaultMiddleware().concat(cryptoBannerApi.middleware), + }); + + const Wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + return Wrapper; +}; + +describe("useCryptoBanner", () => { + it("should return initial state", () => { + const { result } = renderHook(() => useCryptoBanner({ product: "llm", version: "1.0.0" }), { + wrapper: createWrapper(), + }); + + expect(result.current.topCryptos).toEqual([]); + expect(result.current.isLoading).toBeDefined(); + }); +}); + diff --git a/features/cryptoBanner/src/hooks/useCryptoBanner.ts b/features/cryptoBanner/src/hooks/useCryptoBanner.ts new file mode 100644 index 00000000000..d94ba183753 --- /dev/null +++ b/features/cryptoBanner/src/hooks/useCryptoBanner.ts @@ -0,0 +1,15 @@ +import { useGetTopCryptosQuery } from "../data-layer/api/cryptoBanner.api"; +import { GetTopCryptosParams } from "../data-layer/api/types"; + +export const useCryptoBanner = (params: GetTopCryptosParams) => { + const { data, isLoading, error, refetch } = useGetTopCryptosQuery(params, { + pollingInterval: 30000, + }); + + return { + topCryptos: data || [], + isLoading, + error, + refetch, + }; +}; diff --git a/features/cryptoBanner/src/index.native.ts b/features/cryptoBanner/src/index.native.ts new file mode 100644 index 00000000000..a669c6753b1 --- /dev/null +++ b/features/cryptoBanner/src/index.native.ts @@ -0,0 +1,17 @@ +// Entry point for React Native +export { CryptoBanner } from "./components/CryptoBanner/index.native"; +export { cryptoBannerApi, useGetTopCryptosQuery } from "./data-layer/api/cryptoBanner.api"; +export { + cryptoBannerReducer, + toggleBanner, + setAutoScroll, + setScrollSpeed, +} from "./data-layer/entities/cryptoBanner/cryptoBannerSlice"; +export { + selectIsEnabled, + selectAutoScroll, + selectScrollSpeed, +} from "./data-layer/entities/cryptoBanner/cryptoBannerSelectors"; +export { useCryptoBanner } from "./hooks/useCryptoBanner"; +export type { TopCrypto, GetTopCryptosParams } from "./data-layer/api/types"; +export { CRYPTO_BANNER_ROUTES } from "./routes"; diff --git a/features/cryptoBanner/src/index.ts b/features/cryptoBanner/src/index.ts new file mode 100644 index 00000000000..42df53d9e03 --- /dev/null +++ b/features/cryptoBanner/src/index.ts @@ -0,0 +1,17 @@ +// Entry point for Web/Desktop +export { CryptoBanner } from "./components/CryptoBanner/index.web"; +export { cryptoBannerApi, useGetTopCryptosQuery } from "./data-layer/api/cryptoBanner.api"; +export { + cryptoBannerReducer, + toggleBanner, + setAutoScroll, + setScrollSpeed, +} from "./data-layer/entities/cryptoBanner/cryptoBannerSlice"; +export { + selectIsEnabled, + selectAutoScroll, + selectScrollSpeed, +} from "./data-layer/entities/cryptoBanner/cryptoBannerSelectors"; +export { useCryptoBanner } from "./hooks/useCryptoBanner"; +export type { TopCrypto, GetTopCryptosParams } from "./data-layer/api/types"; +export { CRYPTO_BANNER_ROUTES } from "./routes"; diff --git a/features/cryptoBanner/src/routes/index.ts b/features/cryptoBanner/src/routes/index.ts new file mode 100644 index 00000000000..725647b4a57 --- /dev/null +++ b/features/cryptoBanner/src/routes/index.ts @@ -0,0 +1,3 @@ +export const CRYPTO_BANNER_ROUTES = { + BANNER: "/crypto-banner", +} as const; diff --git a/features/cryptoBanner/src/types/state.ts b/features/cryptoBanner/src/types/state.ts new file mode 100644 index 00000000000..749c8a400a8 --- /dev/null +++ b/features/cryptoBanner/src/types/state.ts @@ -0,0 +1,10 @@ +import { cryptoBannerApi } from "../data-layer/api/cryptoBanner.api"; + +export interface RootState { + cryptoBanner: { + isEnabled: boolean; + autoScroll: boolean; + scrollSpeed: number; + }; + [cryptoBannerApi.reducerPath]: ReturnType; +} diff --git a/features/cryptoBanner/tsconfig.json b/features/cryptoBanner/tsconfig.json new file mode 100644 index 00000000000..ddb82a17fb3 --- /dev/null +++ b/features/cryptoBanner/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./lib", + "declaration": true, + "declarationMap": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": [ + "node_modules", + "lib", + "lib-es", + "**/*.test.ts", + "**/*.test.tsx", + "**/__integrations__/**" + ] +} diff --git a/features/cryptoBanner/tsup.config.ts b/features/cryptoBanner/tsup.config.ts new file mode 100644 index 00000000000..53a4ea71be3 --- /dev/null +++ b/features/cryptoBanner/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from "tsup"; + +export default defineConfig([ + { + name: "web", + entry: { index: "src/index.ts" }, + format: ["cjs", "esm"], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + platform: "browser", + outDir: "dist/web", + external: ["react", "react-dom", "react-redux", "@reduxjs/toolkit", "@ledgerhq/live-env"], + }, + { + name: "native", + entry: { "index.native": "src/index.native.ts" }, + format: ["cjs", "esm"], + dts: true, + splitting: false, + sourcemap: true, + clean: false, + treeshake: true, + platform: "neutral", + outDir: "dist/native", + external: ["react", "react-native", "react-redux", "@reduxjs/toolkit", "@ledgerhq/live-env"], + }, +]); diff --git a/package.json b/package.json index 24a25dc05a8..af12dbe22c0 100644 --- a/package.json +++ b/package.json @@ -207,7 +207,8 @@ "ui:example:next": "pnpm --filter=\"next.js-example\"", "ui:example:webpack": "pnpm --filter=\"webpack.js-example\"", "actions": "pnpm --filter @actions/*", - "import:cal-tokens": "pnpm --filter=\"@ledgerhq/cryptoassets\" import:cal-tokens" + "import:cal-tokens": "pnpm --filter=\"@ledgerhq/cryptoassets\" import:cal-tokens", + "feature:crypto-banner": "pnpm --filter @ledgerhq/crypto-banner" }, "devDependencies": { "@changesets/changelog-github": "0.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e21b322fe0e..c937431bd74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,9 @@ importers: '@ledgerhq/coin-framework': specifier: workspace:^ version: link:../../libs/coin-framework + '@ledgerhq/crypto-banner': + specifier: workspace:* + version: link:../../features/cryptoBanner '@ledgerhq/crypto-icons': specifier: 'catalog:' version: 1.4.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@5.3.11(react-dom@18.3.1(react@18.3.1))(react-is@17.0.2)(react@18.3.1)) @@ -1153,6 +1156,9 @@ importers: '@ledgerhq/coin-stacks': specifier: workspace:^ version: link:../../libs/coin-modules/coin-stacks + '@ledgerhq/crypto-banner': + specifier: workspace:* + version: link:../../features/cryptoBanner '@ledgerhq/cryptoassets': specifier: workspace:^ version: link:../../libs/ledgerjs/packages/cryptoassets @@ -2205,6 +2211,49 @@ importers: specifier: 'catalog:' version: 8.18.3 + features/cryptoBanner: + dependencies: + '@ledgerhq/live-env': + specifier: workspace:* + version: link:../../libs/env + '@reduxjs/toolkit': + specifier: 'catalog:' + version: 2.8.2(react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1))(react@18.3.1) + devDependencies: + '@testing-library/react': + specifier: ^14.0.0 + version: 14.2.2(@types/react@18.3.27)(react@18.3.1) + '@types/jest': + specifier: ^29.5.10 + version: 29.5.14 + '@types/react': + specifier: ^18.3.0 + version: 18.3.27 + jest: + specifier: ^29.7.0 + version: 29.7.0 + msw: + specifier: 'catalog:' + version: 2.7.3(typescript@5.8.3) + react: + specifier: 'catalog:' + version: 18.3.1 + react-redux: + specifier: 'catalog:' + version: 9.2.0(@types/react@18.3.27)(react@18.3.1) + rimraf: + specifier: ^4.4.1 + version: 4.4.1 + ts-jest: + specifier: ^29.1.1 + version: 29.4.5(jest@29.7.0)(typescript@5.8.3) + tsup: + specifier: ^8.0.0 + version: 8.5.0(typescript@5.8.3) + typescript: + specifier: ^5.4.3 + version: 5.8.3 + libs/client-ids: dependencies: '@ledgerhq/live-env': @@ -23840,10 +23889,6 @@ packages: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} - consola@3.2.3: - resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} - engines: {node: ^14.18.0 || >=16.10.0} - consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -35557,10 +35602,6 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.14: - resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -36209,9 +36250,6 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.5.3: - resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} - ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -50238,6 +50276,18 @@ snapshots: react: 18.3.1 react-redux: 9.2.0(@types/react@18.2.73)(react@18.3.1) + '@reduxjs/toolkit@2.8.2(react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1))(react@18.3.1)': + dependencies: + '@standard-schema/spec': 1.0.0 + '@standard-schema/utils': 0.3.0 + immer: 10.0.3 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 18.3.1 + react-redux: 9.2.0(@types/react@18.3.27)(react@18.3.1) + '@reduxjs/toolkit@2.9.0(react-redux@9.2.0(@types/react@18.2.73)(react@18.3.1)(redux@5.0.1))(react@19.2.0)': dependencies: '@standard-schema/spec': 1.0.0 @@ -54638,27 +54688,36 @@ snapshots: '@testing-library/react@14.2.2': dependencies: - '@babel/runtime': 7.24.1 + '@babel/runtime': 7.27.1 '@testing-library/dom': 9.3.4 - '@types/react-dom': 18.3.0 + '@types/react-dom': 18.3.7 transitivePeerDependencies: - '@types/react' '@testing-library/react@14.2.2(@types/react@18.2.73)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.24.1 + '@babel/runtime': 7.27.1 '@testing-library/dom': 9.3.4 - '@types/react-dom': 18.3.0(@types/react@18.2.73) + '@types/react-dom': 18.3.7(@types/react@18.2.73) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: - '@types/react' + '@testing-library/react@14.2.2(@types/react@18.3.27)(react@18.3.1)': + dependencies: + '@babel/runtime': 7.27.1 + '@testing-library/dom': 9.3.4 + '@types/react-dom': 18.3.7(@types/react@18.3.27) + react: 18.3.1 + transitivePeerDependencies: + - '@types/react' + '@testing-library/react@14.2.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@babel/runtime': 7.24.1 + '@babel/runtime': 7.27.1 '@testing-library/dom': 9.3.4 - '@types/react-dom': 18.3.0 + '@types/react-dom': 18.3.7 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -55376,12 +55435,16 @@ snapshots: optionalDependencies: '@types/react': 18.2.73 - '@types/react-dom@18.3.0': {} - '@types/react-dom@18.3.0(@types/react@18.2.73)': optionalDependencies: '@types/react': 18.2.73 + '@types/react-dom@18.3.7': {} + + '@types/react-dom@18.3.7(@types/react@18.2.73)': + dependencies: + '@types/react': 18.2.73 + '@types/react-dom@18.3.7(@types/react@18.3.27)': dependencies: '@types/react': 18.3.27 @@ -56401,7 +56464,7 @@ snapshots: '@vue/component-compiler-utils': 3.3.0(lodash@4.17.21)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@vue/vue-loader-v15': vue-loader@15.11.1(css-loader@6.10.0(webpack@5.94.0(@swc/core@1.4.11)))(lodash@4.17.21)(prettier@3.2.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(vue-template-compiler@2.7.16)(webpack@5.94.0(@swc/core@1.4.11)) '@vue/web-component-wrapper': 1.3.0 - acorn: 8.13.0 + acorn: 8.15.0 acorn-walk: 8.3.2 address: 1.2.2 autoprefixer: 10.4.19(postcss@8.5.6) @@ -57036,18 +57099,22 @@ snapshots: acorn-globals@7.0.1: dependencies: - acorn: 8.13.0 + acorn: 8.15.0 acorn-walk: 8.3.2 - acorn-import-assertions@1.9.0(acorn@8.13.0): + acorn-import-assertions@1.9.0(acorn@8.15.0): dependencies: - acorn: 8.13.0 + acorn: 8.15.0 optional: true acorn-import-attributes@1.9.5(acorn@8.13.0): dependencies: acorn: 8.13.0 + acorn-import-attributes@1.9.5(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@7.4.1): dependencies: acorn: 7.4.1 @@ -59313,7 +59380,7 @@ snapshots: citty@0.1.6: dependencies: - consola: 3.2.3 + consola: 3.4.2 cjs-module-lexer@1.2.3: {} @@ -59713,8 +59780,6 @@ snapshots: transitivePeerDependencies: - supports-color - consola@3.2.3: {} - consola@3.4.2: {} console-browserify@1.2.0: {} @@ -64359,7 +64424,7 @@ snapshots: giget@1.2.3: dependencies: citty: 0.1.6 - consola: 3.2.3 + consola: 3.4.2 defu: 6.1.4 node-fetch-native: 1.6.4 nypm: 0.3.8 @@ -65252,15 +65317,15 @@ snapshots: import-in-the-middle@1.10.0: dependencies: - acorn: 8.13.0 - acorn-import-attributes: 1.9.5(acorn@8.13.0) + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 import-in-the-middle@1.4.2: dependencies: - acorn: 8.13.0 - acorn-import-assertions: 1.9.0(acorn@8.13.0) + acorn: 8.15.0 + acorn-import-assertions: 1.9.0(acorn@8.15.0) cjs-module-lexer: 1.2.3 module-details-from-path: 1.0.3 optional: true @@ -65654,7 +65719,7 @@ snapshots: is-reference@1.2.1: dependencies: - '@types/estree': 1.0.6 + '@types/estree': 1.0.8 is-regex@1.1.4: dependencies: @@ -66175,7 +66240,7 @@ snapshots: chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 27.5.1 jest-util: 27.5.1 jest-validate: 27.5.1 @@ -66197,7 +66262,7 @@ snapshots: chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 28.1.3(@types/node@22.10.10) jest-util: 28.1.3 jest-validate: 28.1.3 @@ -66217,7 +66282,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0 exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0 jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66237,7 +66302,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66257,7 +66322,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(typescript@5.4.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(typescript@5.4.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66277,7 +66342,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(@types/node@22.10.1)(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.4.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(@types/node@22.10.1)(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.4.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66297,7 +66362,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(@types/node@22.10.10) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(@types/node@22.10.10) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66317,7 +66382,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@swc/core@1.4.11)(@types/node@22.10.10)(typescript@5.1.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@swc/core@1.4.11)(@types/node@22.10.10)(typescript@5.1.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66337,7 +66402,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@types/node@22.10.10)(source-map-support@0.5.21)(typescript@5.4.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@types/node@22.10.10)(source-map-support@0.5.21)(typescript@5.4.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66357,7 +66422,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@types/node@22.10.10)(typescript@5.4.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@types/node@22.10.10)(typescript@5.4.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66377,7 +66442,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.4.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.4.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66397,7 +66462,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.6.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.6.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66417,7 +66482,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(ts-node@10.9.2(typescript@5.4.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(ts-node@10.9.2(typescript@5.4.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66437,7 +66502,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(ts-node@10.9.2(typescript@5.6.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(ts-node@10.9.2(typescript@5.6.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -66457,7 +66522,7 @@ snapshots: chalk: 4.1.2 create-jest: 29.7.0(ts-node@10.9.2(typescript@5.8.3)) exit: 0.1.2 - import-local: 3.1.0 + import-local: 3.2.0 jest-config: 29.7.0(ts-node@10.9.2(typescript@5.8.3)) jest-util: 29.7.0 jest-validate: 29.7.0 @@ -68529,7 +68594,7 @@ snapshots: jest@27.5.1: dependencies: '@jest/core': 27.5.1 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 27.5.1 transitivePeerDependencies: - bufferutil @@ -68555,7 +68620,7 @@ snapshots: dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0 transitivePeerDependencies: - '@types/node' @@ -68568,7 +68633,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(source-map-support@0.5.21)(typescript@5.4.3)) transitivePeerDependencies: - '@types/node' @@ -68581,7 +68646,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.12.12)(typescript@5.4.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(@types/node@20.12.12)(ts-node@10.9.2(@types/node@20.12.12)(typescript@5.4.3)) transitivePeerDependencies: - '@types/node' @@ -68594,7 +68659,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.4.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(@types/node@22.10.1)(ts-node@10.9.2(@types/node@22.10.1)(typescript@5.4.3)) transitivePeerDependencies: - '@types/node' @@ -68607,7 +68672,7 @@ snapshots: dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(@types/node@22.10.10) transitivePeerDependencies: - '@types/node' @@ -68620,7 +68685,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(@types/node@22.10.10)(typescript@5.1.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@swc/core@1.4.11)(@types/node@22.10.10)(typescript@5.1.3)) transitivePeerDependencies: - '@types/node' @@ -68633,7 +68698,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.10.10)(source-map-support@0.5.21)(typescript@5.4.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@types/node@22.10.10)(source-map-support@0.5.21)(typescript@5.4.3)) transitivePeerDependencies: - '@types/node' @@ -68646,7 +68711,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.10.10)(typescript@5.4.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(@types/node@22.10.10)(ts-node@10.9.2(@types/node@22.10.10)(typescript@5.4.3)) transitivePeerDependencies: - '@types/node' @@ -68659,7 +68724,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.4.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.4.3)) transitivePeerDependencies: - '@types/node' @@ -68672,7 +68737,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.6.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(ts-node@10.9.2(@swc/core@1.4.11)(typescript@5.6.3)) transitivePeerDependencies: - '@types/node' @@ -68685,7 +68750,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(typescript@5.4.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(ts-node@10.9.2(typescript@5.4.3)) transitivePeerDependencies: - '@types/node' @@ -68698,7 +68763,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(typescript@5.6.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(ts-node@10.9.2(typescript@5.6.3)) transitivePeerDependencies: - '@types/node' @@ -68711,7 +68776,7 @@ snapshots: dependencies: '@jest/core': 29.7.0(ts-node@10.9.2(typescript@5.8.3)) '@jest/types': 29.6.3 - import-local: 3.1.0 + import-local: 3.2.0 jest-cli: 29.7.0(ts-node@10.9.2(typescript@5.8.3)) transitivePeerDependencies: - '@types/node' @@ -68940,7 +69005,7 @@ snapshots: jsdom@16.7.0: dependencies: abab: 2.0.6 - acorn: 8.13.0 + acorn: 8.15.0 acorn-globals: 6.0.0 cssom: 0.4.4 cssstyle: 2.3.0 @@ -68974,7 +69039,7 @@ snapshots: jsdom@17.0.0: dependencies: abab: 2.0.6 - acorn: 8.13.0 + acorn: 8.15.0 acorn-globals: 6.0.0 cssom: 0.5.0 cssstyle: 2.3.0 @@ -71837,10 +71902,10 @@ snapshots: nypm@0.3.8: dependencies: citty: 0.1.6 - consola: 3.2.3 + consola: 3.4.2 execa: 8.0.1 pathe: 1.1.2 - ufo: 1.5.3 + ufo: 1.6.1 oauth-sign@0.9.0: {} @@ -74647,6 +74712,14 @@ snapshots: '@types/react': 18.2.73 redux: 5.0.1 + react-redux@9.2.0(@types/react@18.3.27)(react@18.3.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 18.3.1 + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.27 + react-refresh@0.10.0: {} react-refresh@0.11.0: {} @@ -77588,14 +77661,14 @@ snapshots: schema-utils: 3.3.0 serialize-javascript: 6.0.2 terser: 5.36.0 - webpack: 5.94.0 + webpack: 5.94.0(webpack-cli@4.10.0) transitivePeerDependencies: - metro terser@5.36.0: dependencies: '@jridgewell/source-map': 0.3.6 - acorn: 8.13.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -77710,11 +77783,6 @@ snapshots: tinyexec@0.3.2: {} - tinyglobby@0.2.14: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -78340,6 +78408,20 @@ snapshots: optionalDependencies: jest-util: 30.2.0 + ts-jest@29.4.5(jest@29.7.0)(typescript@5.8.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.8.3 + yargs-parser: 21.1.1 + ts-loader@9.5.1(typescript@5.1.3)(webpack@5.94.0(@swc/core@1.4.11)): dependencies: chalk: 4.1.2 @@ -78658,7 +78740,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.0 + debug: 4.4.1 esbuild: 0.25.5 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -78669,7 +78751,7 @@ snapshots: source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.14 + tinyglobby: 0.2.15 tree-kill: 1.2.2 optionalDependencies: typescript: 5.8.3 @@ -78902,8 +78984,6 @@ snapshots: uc.micro@2.1.0: {} - ufo@1.5.3: {} - ufo@1.6.1: {} uglify-es@3.3.9: @@ -79141,7 +79221,7 @@ snapshots: unplugin@1.10.0: dependencies: - acorn: 8.13.0 + acorn: 8.15.0 chokidar: 3.6.0 webpack-sources: 3.2.3 webpack-virtual-modules: 0.6.1 @@ -80043,7 +80123,7 @@ snapshots: webpack-bundle-analyzer@4.10.2: dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.13.0 + acorn: 8.15.0 acorn-walk: 8.3.2 commander: 7.2.0 debounce: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bf87b7ca187..82b0f2aa21c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "apps/*" - "e2e/*" + - "features/*" - "libs/*" - "tests/*" - "libs/coin-modules/*"