diff --git a/packages/wallets/component-tests/wallets-carousel-content.spec.tsx b/packages/wallets/component-tests/wallets-carousel-content.spec.tsx index 5494875977f0..457f5331f1a9 100644 --- a/packages/wallets/component-tests/wallets-carousel-content.spec.tsx +++ b/packages/wallets/component-tests/wallets-carousel-content.spec.tsx @@ -104,6 +104,9 @@ test.describe('Wallets - Mobile carousel', () => { expect(progressBarItemClass2).toContain('wallets-progress-bar-active'); + // timeout to wait for previous swiping animation + await mobilePage.waitForTimeout(1000); + await swipeLeft(mobilePage); const activeProgressBarItem3 = mobilePage.locator('.wallets-progress-bar div:nth-child(3)'); diff --git a/packages/wallets/src/components/WalletCard/WalletCard.scss b/packages/wallets/src/components/WalletCard/WalletCard.scss index 0c3debdd979a..0edbe1cdcc45 100644 --- a/packages/wallets/src/components/WalletCard/WalletCard.scss +++ b/packages/wallets/src/components/WalletCard/WalletCard.scss @@ -1,9 +1,31 @@ @import '../../components/SkeletonLoader/SkeletonLoader.scss'; .wallets-card { - display: flex; - flex-direction: column; - align-items: center; + &__carousel-content { + background-color: transparent; + margin: 0 -0.8rem; + + &:first-child { + margin-left: 0; + } + + &-details { + padding: 1.6rem; + width: 28.8rem; + height: 17.6rem; + + &-top { + justify-content: flex-start; + width: unset; + } + } + } + + &__container { + display: flex; + flex-direction: column; + align-items: center; + } &__balance-loader { padding: 0.7rem; @@ -21,37 +43,21 @@ align-items: flex-start; aspect-ratio: 1.667; - &__carousel-content { - padding: 1.6rem; - width: 24rem; - height: 14.6rem; - - &--active { - width: 28.8rem; - height: 17.6rem; - } - } - - &__top { + &-top { display: flex; flex-direction: row; width: 100%; justify-content: space-between; - - &__carousel-content { - justify-content: flex-start; - width: unset; - } } - &__bottom { + &-bottom { display: flex; flex-direction: column; align-items: flex-start; align-self: stretch; } - &-landing_company { + &-landing-company { display: flex; justify-content: flex-end; align-items: flex-start; diff --git a/packages/wallets/src/components/WalletCard/WalletCard.tsx b/packages/wallets/src/components/WalletCard/WalletCard.tsx index 259f0b88d302..ac98125f4a9d 100644 --- a/packages/wallets/src/components/WalletCard/WalletCard.tsx +++ b/packages/wallets/src/components/WalletCard/WalletCard.tsx @@ -11,7 +11,6 @@ type TProps = { balance: string; currency: string; iconSize?: React.ComponentProps['size']; - isActive?: boolean; isCarouselContent?: boolean; isDemo?: boolean; landingCompanyName?: string; @@ -21,7 +20,6 @@ const WalletCard: React.FC = ({ balance, currency, iconSize = 'lg', - isActive = false, isCarouselContent = false, isDemo, landingCompanyName, @@ -29,56 +27,57 @@ const WalletCard: React.FC = ({ const { isLoading } = useBalance(); return ( -
- -
+
+
- - {!isCarouselContent && ( -
- {landingCompanyName && ( - - )} -
- )} -
-
- - {currency} Wallet - - {isLoading ? ( -
- ) : ( - - {balance} +
+ + {!isCarouselContent && ( +
+ {landingCompanyName && ( + + )} +
+ )} +
+
+ + {currency} Wallet - )} + {isLoading ? ( +
+ ) : ( + + {balance} + + )} +
-
- + +
); }; diff --git a/packages/wallets/src/components/WalletCard/__tests__/WalletCard.spec.tsx b/packages/wallets/src/components/WalletCard/__tests__/WalletCard.spec.tsx index 6e1fa251a0a5..d7fac1854e6e 100644 --- a/packages/wallets/src/components/WalletCard/__tests__/WalletCard.spec.tsx +++ b/packages/wallets/src/components/WalletCard/__tests__/WalletCard.spec.tsx @@ -67,7 +67,6 @@ describe('WalletCard', () => { mockProps = { balance: '100 BTC', currency: 'BTC', - isActive: true, isCarouselContent: true, landingCompanyName: 'Deriv', }; @@ -82,7 +81,7 @@ describe('WalletCard', () => { const gradient = screen.getByTestId('dt_wallet_gradient_background'); expect(gradient).toHaveClass('wallets-gradient--BTC-mobile-card-light'); const details = screen.getByTestId('dt_wallet_card_details'); - expect(details).toHaveClass('wallets-card__details__carousel-content--active'); + expect(details).toHaveClass('wallets-card__details wallets-card__carousel-content-details'); }); it('should render the correct wallet card and gradient background for demo wallet', () => { diff --git a/packages/wallets/src/components/WalletsCarousel/WalletsCarousel.scss b/packages/wallets/src/components/WalletsCarousel/WalletsCarousel.scss new file mode 100644 index 000000000000..3f4e8f44d268 --- /dev/null +++ b/packages/wallets/src/components/WalletsCarousel/WalletsCarousel.scss @@ -0,0 +1,3 @@ +.wallets-carousel { + width: 100%; +} diff --git a/packages/wallets/src/components/WalletsCarousel/WalletsCarousel.tsx b/packages/wallets/src/components/WalletsCarousel/WalletsCarousel.tsx index 449d66b1355a..cd7c735aea16 100644 --- a/packages/wallets/src/components/WalletsCarousel/WalletsCarousel.tsx +++ b/packages/wallets/src/components/WalletsCarousel/WalletsCarousel.tsx @@ -1,24 +1,59 @@ -import React, { useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useEventListener } from 'usehooks-ts'; import { useActiveWalletAccount } from '@deriv/api-v2'; import { AccountsList } from '../AccountsList'; import { WalletsCarouselContent } from '../WalletsCarouselContent'; import { WalletsCarouselHeader } from '../WalletsCarouselHeader'; +import './WalletsCarousel.scss'; const WalletsCarousel: React.FC = () => { const [isWalletSettled, setIsWalletSettled] = useState(true); + const [showWalletsCarouselHeader, setShowWalletsCarouselHeader] = useState(false); + const [heightFromTop, setHeightFromTop] = useState(0); const { data: activeWallet, isLoading: isActiveWalletLoading } = useActiveWalletAccount(); + const containerRef = useRef(null); + + // function to handle scrolling event for hiding/displaying WalletsCarouselHeader + // walletsCarouselHeader will be displayed when height from top of screen is more than 100px + const handleScroll = useCallback(() => { + if (containerRef.current) { + const newHeightFromTop = containerRef.current.getBoundingClientRect().top; + setHeightFromTop(newHeightFromTop); + heightFromTop && setShowWalletsCarouselHeader(heightFromTop < -100); + } + }, [heightFromTop]); + + //this handle scroll function listens to the scroll as well as touchmove events to handle drag scrolling on mobile + useEventListener('touchmove', handleScroll, containerRef); + useEventListener('scroll', handleScroll, containerRef); + + useEffect(() => { + let isMounted = true; + + if (isMounted) { + handleScroll(); + } + + return () => { + isMounted = false; + }; + }, [handleScroll, heightFromTop]); + return ( {!isActiveWalletLoading && ( ); }; diff --git a/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.scss b/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.scss index e3c9fb2a6fe5..ac19aa16d0b9 100644 --- a/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.scss +++ b/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.scss @@ -3,14 +3,12 @@ width: 100vw; padding: 2rem; overflow: hidden; - gap: 1.6rem; &__container { height: 17.6rem; display: flex; align-items: center; justify-content: flex-start; - gap: 1.6rem; & > .wallets-card__details { width: 45vw; diff --git a/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.tsx b/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.tsx index 091e9d40b66f..53ee7082ef17 100644 --- a/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.tsx +++ b/packages/wallets/src/components/WalletsCarouselContent/WalletsCarouselContent.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useRef, useState } from 'react'; -import useEmblaCarousel from 'embla-carousel-react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import useEmblaCarousel, { EmblaCarouselType, EmblaEventType } from 'embla-carousel-react'; import { useActiveWalletAccount, useAuthorize, useCurrencyConfig, useMobileCarouselWalletsList } from '@deriv/api-v2'; import { ProgressBar } from '../Base'; import { WalletsCarouselLoader } from '../SkeletonLoader'; @@ -11,6 +11,11 @@ type TProps = { onWalletSettled?: (value: boolean) => void; }; +const numberWithinRange = (number: number, min: number, max: number): number => Math.min(Math.max(number, min), max); + +// scale based on the width difference between active wallet (288px) and inactive wallets (240px) +const TRANSITION_FACTOR_SCALE = 1 - 24 / 28.8; + /** * carousel component * idea behind data flow here: @@ -30,6 +35,71 @@ const WalletsCarouselContent: React.FC = ({ onWalletSettled }) => { // for the embla "on select" callback // to avoid unbinding / cleaning etc, just let it use up-to-date list const walletsAccountsListRef = useRef(walletAccountsList); + const transitionNodes = useRef([]); + const transitionFactor = useRef(0); + + // sets the transition nodes to be scaled + const setTransitionNodes = useCallback((walletsCarouselEmblaApi: EmblaCarouselType) => { + // find and store all available wallet card containers for the transition nodes + transitionNodes.current = walletsCarouselEmblaApi.slideNodes().map(slideNode => { + return slideNode.querySelector('.wallets-card__container') as HTMLElement; + }); + }, []); + + // function to set the transition factor based on the number of scroll snaps + const setTransitionFactor = useCallback((walletsCarouselEmblaApi: EmblaCarouselType) => { + transitionFactor.current = TRANSITION_FACTOR_SCALE * walletsCarouselEmblaApi.scrollSnapList().length; + }, []); + + // function to interpolate the scale of wallet cards based on scroll events + const transitionScale = useCallback( + (walletsCarouselEmblaApi: EmblaCarouselType, walletsCarouselEvent?: EmblaEventType) => { + const engine = walletsCarouselEmblaApi.internalEngine(); + const scrollProgress = walletsCarouselEmblaApi.scrollProgress(); + const slidesInView = walletsCarouselEmblaApi.slidesInView(); + const isScrollEvent = walletsCarouselEvent === 'scroll'; + + walletsCarouselEmblaApi.scrollSnapList().forEach((scrollSnap, snapIndex) => { + //scrollProgress returns the progress for the whole list with a value of 0 { + if (isScrollEvent && !slidesInView.includes(slideIndex)) return; + + // iterate through the loop points in the carousel engine using the embla API internal engine + if (engine.options.loop) { + engine.slideLooper.loopPoints.forEach(loopItem => { + const target = loopItem.target(); + + // determine the direction of the loop based on the sign and adjust the difference to the target based on loop direction + if (slideIndex === loopItem.index && target !== 0) { + const sign = Math.sign(target); + + if (sign === -1) { + diffToTarget = scrollSnap - (1 + scrollProgress); + } + if (sign === 1) { + diffToTarget = scrollSnap + (1 - scrollProgress); + } + } + }); + } + + // calculate transition scale value based on the scroll position + // active wallet will scale down until it reaches the point where width is 24rem and vice versa + const transitionValue = 1 - Math.abs(diffToTarget * transitionFactor.current); + const scale = numberWithinRange(transitionValue, 0, 1).toString(); + + // apply the scale to the wallet cards + const transitionNode = transitionNodes.current[slideIndex]; + transitionNode.style.transform = `scale(${scale})`; + }); + }); + }, + [] + ); const [walletsCarouselEmblaRef, walletsCarouselEmblaApi] = useEmblaCarousel({ containScroll: false, @@ -63,6 +133,22 @@ const WalletsCarouselContent: React.FC = ({ onWalletSettled }) => { walletsCarouselEmblaApi?.on('settle', () => { onWalletSettled?.(true); }); + + return () => { + walletsCarouselEmblaApi?.off('select', () => { + const index = walletsCarouselEmblaApi?.selectedScrollSnap(); + if (index === undefined) { + return; + } + const loginId = walletsAccountsListRef?.current?.[index]?.loginid; + + loginId && setSelectedLoginId(loginId); + }); + + walletsCarouselEmblaApi?.off('settle', () => { + onWalletSettled?.(true); + }); + }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [walletsCarouselEmblaApi]); @@ -81,9 +167,27 @@ const WalletsCarouselContent: React.FC = ({ onWalletSettled }) => { if (walletsCarouselEmblaApi && isInitialDataLoaded) { const index = walletAccountsList?.findIndex(({ loginid }) => loginid === selectedLoginId) ?? -1; walletsCarouselEmblaApi?.scrollTo(index, true); + + walletsCarouselEmblaApi && setTransitionNodes(walletsCarouselEmblaApi); + walletsCarouselEmblaApi && setTransitionFactor(walletsCarouselEmblaApi); + walletsCarouselEmblaApi && transitionScale(walletsCarouselEmblaApi); + + walletsCarouselEmblaApi + ?.on('reInit', setTransitionNodes) + .on('reInit', setTransitionFactor) + .on('reInit', transitionScale) + .on('scroll', transitionScale); + + return () => { + walletsCarouselEmblaApi + ?.off('reInit', setTransitionNodes) + .off('reInit', setTransitionFactor) + .off('reInit', transitionScale) + .off('scroll', transitionScale); + }; } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletsCarouselEmblaApi, isInitialDataLoaded]); + }, [walletsCarouselEmblaApi, isInitialDataLoaded, transitionScale]); useEffect(() => { const index = walletAccountsList?.findIndex(({ loginid }) => loginid === selectedLoginId) ?? -1; @@ -112,7 +216,6 @@ const WalletsCarouselContent: React.FC = ({ onWalletSettled }) => { balance={account.display_balance} currency={account.currency || 'USD'} iconSize='xl' - isActive={account.is_active} isCarouselContent isDemo={account.is_virtual} key={`wallet-card-${account.loginid}`} diff --git a/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.scss b/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.scss index 5f0a7df4dea2..54599a002dd8 100644 --- a/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.scss +++ b/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.scss @@ -3,14 +3,20 @@ .wallets-carousel-header { background: var(--system-light-8-primary-background, #fff); border-bottom: 1px solid var(--general-section-1, #f2f3f4); - display: none; //TODO: hidden for now and will be displayed in the next PR when animation is added + display: flex; height: 6.1rem; justify-content: space-between; padding: 1rem 1.6rem 1rem 0.8rem; position: absolute; top: 0; - width: 100vw; + width: 100%; z-index: 1; + opacity: 1; + transition: opacity 0.3s ease-in; + + &--hidden { + opacity: 0; + } &__balance-loader { padding: 0.7rem; diff --git a/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.tsx b/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.tsx index fc891eb93fe1..86d859425a39 100644 --- a/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.tsx +++ b/packages/wallets/src/components/WalletsCarouselHeader/WalletsCarouselHeader.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import classNames from 'classnames'; import { useHistory } from 'react-router-dom'; import { useBalance } from '@deriv/api-v2'; import IcCashierTransfer from '../../public/images/ic-cashier-transfer.svg'; @@ -9,15 +10,16 @@ import './WalletsCarouselHeader.scss'; type TProps = { balance?: string; currency: string; + hidden?: boolean; isDemo?: boolean; }; -const WalletsCarouselHeader: React.FC = ({ balance, currency, isDemo }) => { +const WalletsCarouselHeader: React.FC = ({ balance, currency, hidden, isDemo }) => { const history = useHistory(); const { isLoading } = useBalance(); return ( -
+