From de06d077cfe31549f194f26a42ebeb2604579573 Mon Sep 17 00:00:00 2001 From: Xavier Le Cunff Date: Wed, 16 Oct 2024 15:01:10 +0200 Subject: [PATCH] feat: add next movie feature --- .../MovieCalendarContext.tsx | 149 ++++++++++++++++++ .../MovieOfferTile.tsx | 43 +---- .../MoviesScreeningCalendar.tsx | 134 +--------------- .../NextScreeningButton.tsx | 4 +- .../MoviesScreeningCalendar/VenueCalendar.tsx | 72 +++++++++ .../moviesOffer.builder.ts | 11 +- .../offersStockResponse.builder.ts | 3 +- .../OfferBody/OfferBody.native.test.tsx | 11 ++ .../OfferContent/OfferContentBase.tsx | 85 ++++++---- .../OfferEventCardList/OfferEventCardList.tsx | 5 +- .../OfferNewXPCine/CineBlock.native.test.tsx | 88 +++++++++++ .../components/OfferNewXPCine/CineBlock.tsx | 20 ++- .../OfferNewXPCineBlock.native.test.tsx | 22 ++- .../OfferNewXPCine/OfferNewXPCineBlock.tsx | 127 ++------------- .../OfferNewXPCine/OfferNewXPCineContent.tsx | 105 ++++++++++++ .../OfferPlace/OfferPlace.native.test.tsx | 2 + .../useGetVenuesByDay.native.test.ts | 8 +- .../useGetVenueByDay/useGetVenuesByDay.ts | 35 ++-- .../components/VenueContent/VenueContent.tsx | 5 +- src/ui/components/anchor/AnchorContext.tsx | 7 +- src/ui/components/anchor/anchor-name.ts | 2 +- 21 files changed, 601 insertions(+), 337 deletions(-) create mode 100644 src/features/offer/components/MoviesScreeningCalendar/MovieCalendarContext.tsx create mode 100644 src/features/offer/components/MoviesScreeningCalendar/VenueCalendar.tsx create mode 100644 src/features/offer/components/OfferNewXPCine/CineBlock.native.test.tsx create mode 100644 src/features/offer/components/OfferNewXPCine/OfferNewXPCineContent.tsx diff --git a/src/features/offer/components/MoviesScreeningCalendar/MovieCalendarContext.tsx b/src/features/offer/components/MoviesScreeningCalendar/MovieCalendarContext.tsx new file mode 100644 index 00000000000..0cdd30a697d --- /dev/null +++ b/src/features/offer/components/MoviesScreeningCalendar/MovieCalendarContext.tsx @@ -0,0 +1,149 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useEffect, + useState, + PropsWithChildren, +} from 'react' +import { Animated, View, ViewStyle } from 'react-native' +import { FlatList } from 'react-native-gesture-handler' +import { Easing } from 'react-native-reanimated' +import styled from 'styled-components/native' + +import { MovieCalendar } from 'features/offer/components/MovieCalendar/MovieCalendar' +import { handleMovieCalendarScroll } from 'features/offer/components/MoviesScreeningCalendar/utils' +import { useNextDays } from 'features/offer/helpers/useNextDays/useNextDays' +import { Anchor } from 'ui/components/anchor/Anchor' +import { useScrollToAnchor } from 'ui/components/anchor/AnchorContext' +import { useLayout } from 'ui/hooks/useLayout' + +type MovieCalendarContextType = { + selectedDate: Date + goToDate: (date: Date) => void +} + +const MovieCalendarContext = createContext(undefined) + +const AnimatedCalendarView: React.FC> = ({ + selectedDate, + children, +}) => { + const fadeAnim = useRef(new Animated.Value(0)).current + const translateAnim = useRef(new Animated.Value(0)).current + const [width, setWidth] = useState(0) + + useEffect(() => { + translateAnim.setValue(0) + fadeAnim.setValue(0) + Animated.timing(translateAnim, { + toValue: 1, + duration: 200, + useNativeDriver: true, + easing: Easing.out(Easing.ease), + }).start() + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + easing: Easing.in(Easing.ease), + useNativeDriver: true, + }).start() + }, [fadeAnim, translateAnim, selectedDate]) + + return ( + + { + setWidth(nativeEvent.layout.width) + }} + style={{ + opacity: fadeAnim, + transform: [ + { translateX: Animated.subtract(Animated.multiply(translateAnim, width), width) }, + ], + }}> + {children} + + + ) +} + +export const MovieCalendarProvider: React.FC<{ + nbOfDays: number + children: React.ReactNode + containerStyle?: ViewStyle +}> = ({ nbOfDays, containerStyle, children }) => { + const { dates, selectedDate, setSelectedDate } = useNextDays(nbOfDays) + const flatListRef = useRef(null) + const { width: flatListWidth, onLayout: onFlatListLayout } = useLayout() + const { width: itemWidth, onLayout: onItemLayout } = useLayout() + const scrollToAnchor = useScrollToAnchor() + + const scrollToMiddleElement = useCallback( + (currentIndex: number) => { + const { offset } = handleMovieCalendarScroll(currentIndex, flatListWidth, itemWidth) + + flatListRef.current?.scrollToOffset({ + animated: true, + offset, + }) + }, + [flatListRef, flatListWidth, itemWidth] + ) + + useEffect(() => { + const currentIndex = dates.findIndex( + (date) => (date as Date).toDateString() === selectedDate.toDateString() + ) + + scrollToMiddleElement(currentIndex) + }, [selectedDate, dates, scrollToMiddleElement]) + + useEffect(() => { + if (flatListRef?.current) { + flatListRef.current?.scrollToOffset({ offset: 0 }) + } + }, [flatListRef]) + + const goToDate = useCallback( + (date: Date) => { + scrollToAnchor('movie-calendar') + setSelectedDate(date) + }, + [scrollToAnchor, setSelectedDate] + ) + + const value = useMemo(() => ({ selectedDate, goToDate }), [selectedDate, goToDate]) + + return ( + + + + + + + {children} + + ) +} + +const AnimationContainer = styled.View({ overflow: 'hidden' }) + +export const useMovieCalendar = (): MovieCalendarContextType => { + const context = useContext(MovieCalendarContext) + if (context === undefined) { + throw new Error('useMovieCalendar must be used within a MovieCalendarProvider') + } + return context +} diff --git a/src/features/offer/components/MoviesScreeningCalendar/MovieOfferTile.tsx b/src/features/offer/components/MoviesScreeningCalendar/MovieOfferTile.tsx index cb4baa08dc9..c83a95228e4 100644 --- a/src/features/offer/components/MoviesScreeningCalendar/MovieOfferTile.tsx +++ b/src/features/offer/components/MoviesScreeningCalendar/MovieOfferTile.tsx @@ -1,5 +1,5 @@ -import React, { FC, useCallback, useMemo } from 'react' -import { FlatList, View } from 'react-native' +import React, { FC, useMemo } from 'react' +import { View } from 'react-native' import styled from 'styled-components/native' import type { OfferPreviewResponse } from 'api/gen' @@ -9,13 +9,12 @@ import { } from 'features/offer/components/MovieScreeningCalendar/useMovieScreeningCalendar' import { useSelectedDateScreening } from 'features/offer/components/MovieScreeningCalendar/useSelectedDateScreenings' import { MovieOffer } from 'features/offer/components/MoviesScreeningCalendar/getNextMoviesByDate' +import { useMovieCalendar } from 'features/offer/components/MoviesScreeningCalendar/MovieCalendarContext' import { NextScreeningButton } from 'features/offer/components/MoviesScreeningCalendar/NextScreeningButton' -import { handleMovieCalendarScroll } from 'features/offer/components/MoviesScreeningCalendar/utils' import { useOfferCTAButton } from 'features/offer/components/OfferCTAButton/useOfferCTAButton' import { formatDuration } from 'features/offer/helpers/formatDuration/formatDuration' import { VenueOffers } from 'features/venue/api/useVenueOffers' import { useSubcategoriesMapping } from 'libs/subcategories' -import { useScrollToAnchor } from 'ui/components/anchor/AnchorContext' import { EventCardList } from 'ui/components/eventCard/EventCardList' import { HorizontalOfferTile } from 'ui/components/tiles/HorizontalOfferTile' import { Spacer } from 'ui/theme' @@ -23,34 +22,22 @@ import { Spacer } from 'ui/theme' type MovieOfferTileProps = { movieOffer: MovieOffer venueOffers: VenueOffers - date: Date isLast: boolean nextScreeningDate?: Date - setSelectedDate: (date: Date) => void - nextDateIndex: number - flatListRef: React.MutableRefObject - flatListWidth: number - itemWidth: number } export const MovieOfferTile: FC = ({ venueOffers, - date, movieOffer: { offer }, isLast, nextScreeningDate, - setSelectedDate, - nextDateIndex, - flatListRef, - flatListWidth, - itemWidth, }) => { const movieScreenings = getMovieScreenings(offer.stocks) - const scrollToAnchor = useScrollToAnchor() + const { goToDate, selectedDate } = useMovieCalendar() const selectedScreeningStock = useMemo( - () => movieScreenings[getDateString(String(date))], - [movieScreenings, date] + () => movieScreenings[getDateString(String(selectedDate))], + [movieScreenings, selectedDate] ) const subcategoriesMapping = useSubcategoriesMapping() @@ -60,18 +47,6 @@ export const MovieOfferTile: FC = ({ offer.isExternalBookingsDisabled ) - const scrollToMiddleElement = useCallback( - (currentIndex: number) => { - const { offset } = handleMovieCalendarScroll(currentIndex, flatListWidth, itemWidth) - - flatListRef.current?.scrollToOffset({ - animated: true, - offset, - }) - }, - [flatListRef, flatListWidth, itemWidth] - ) - const { onPress: onPressOfferCTA, CTAOfferModal, @@ -106,11 +81,7 @@ export const MovieOfferTile: FC = ({ { - scrollToAnchor('venue-calendar') - setSelectedDate(nextScreeningDate) - scrollToMiddleElement(nextDateIndex) - }} + onPress={() => goToDate(nextScreeningDate)} /> ) : ( diff --git a/src/features/offer/components/MoviesScreeningCalendar/MoviesScreeningCalendar.tsx b/src/features/offer/components/MoviesScreeningCalendar/MoviesScreeningCalendar.tsx index 04799cd991b..901ff10bc27 100644 --- a/src/features/offer/components/MoviesScreeningCalendar/MoviesScreeningCalendar.tsx +++ b/src/features/offer/components/MoviesScreeningCalendar/MoviesScreeningCalendar.tsx @@ -1,20 +1,12 @@ import { useRoute } from '@react-navigation/native' -import React, { FunctionComponent, useMemo, useRef, useState, useCallback, useEffect } from 'react' -import { FlatList, Animated, Easing, View } from 'react-native' +import React, { FunctionComponent } from 'react' import styled from 'styled-components/native' import { SubcategoryIdEnum } from 'api/gen' import { UseRouteType } from 'features/navigation/RootNavigator/types' -import { useOffersStocks } from 'features/offer/api/useOffersStocks' -import { MovieCalendar } from 'features/offer/components/MovieCalendar/MovieCalendar' -import { filterOffersStocksByDate } from 'features/offer/components/MoviesScreeningCalendar/filterOffersStocksByDate' -import { - getNextMoviesByDate, - MovieOffer, -} from 'features/offer/components/MoviesScreeningCalendar/getNextMoviesByDate' -import { MovieOfferTile } from 'features/offer/components/MoviesScreeningCalendar/MovieOfferTile' +import { MovieCalendarProvider } from 'features/offer/components/MoviesScreeningCalendar/MovieCalendarContext' +import { VenueCalendar } from 'features/offer/components/MoviesScreeningCalendar/VenueCalendar' import { OfferTile } from 'features/offer/components/OfferTile/OfferTile' -import { useNextDays } from 'features/offer/helpers/useNextDays/useNextDays' import { VenueOffers } from 'features/venue/api/useVenueOffers' import { useFeatureFlag } from 'libs/firebase/firestore/featureFlags/useFeatureFlag' import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' @@ -22,11 +14,9 @@ import { formatDates } from 'libs/parsers/formatDates' import { getDisplayPrice } from 'libs/parsers/getDisplayPrice' import { useCategoryHomeLabelMapping, useCategoryIdMapping } from 'libs/subcategories' import { Offer } from 'shared/offer/types' -import { Anchor } from 'ui/components/anchor/Anchor' import { PassPlaylist } from 'ui/components/PassPlaylist' import { CustomListRenderItem } from 'ui/components/Playlist' import { SectionWithDivider } from 'ui/components/SectionWithDivider' -import { useLayout } from 'ui/hooks/useLayout' import { LENGTH_M, RATIO_HOME_IMAGE, Spacer, TypoDS } from 'ui/theme' import { getHeadingAttrs } from 'ui/theme/typographyAttrs/getHeadingAttrs' @@ -36,86 +26,15 @@ type Props = { const keyExtractor = (item: Offer) => item.objectID -const useMoviesScreeningsList = (offerIds: number[]) => { - const { selectedDate, setSelectedDate, dates } = useNextDays(15) - const { data: offersWithStocks } = useOffersStocks({ offerIds }) - - const moviesOffers: MovieOffer[] = useMemo(() => { - const filteredOffersWithStocks = filterOffersStocksByDate( - offersWithStocks?.offers || [], - selectedDate - ) - const nextScreeningOffers = getNextMoviesByDate(offersWithStocks?.offers || [], selectedDate) - - return [...filteredOffersWithStocks, ...nextScreeningOffers] - }, [offersWithStocks?.offers, selectedDate]).filter( - (offer) => offer.offer.subcategoryId === SubcategoryIdEnum.SEANCE_CINE - ) - - return { - dates, - selectedDate, - setSelectedDate, - moviesOffers, - } -} - export const MoviesScreeningCalendar: FunctionComponent = ({ venueOffers }) => { - const { width: flatListWidth, onLayout: onFlatListLayout } = useLayout() - const { width: itemWidth, onLayout: onItemLayout } = useLayout() const { params: routeParams } = useRoute>() const isNewOfferTileDisplayed = useFeatureFlag(RemoteStoreFeatureFlags.WIP_NEW_OFFER_TILE) const offerIds = venueOffers.hits.map((offer) => Number(offer.objectID)) - const flatListRef = useRef(null) - const fadeAnim = useRef(new Animated.Value(0)).current - const translateAnim = useRef(new Animated.Value(0)).current - const [width, setWidth] = useState(0) - const { - dates: nextFifteenDates, - selectedDate, - setSelectedDate, - moviesOffers, - } = useMoviesScreeningsList(offerIds) const nonScreeningOffers = venueOffers.hits.filter( (offer) => offer.offer.subcategoryId !== SubcategoryIdEnum.SEANCE_CINE ) - useEffect(() => { - translateAnim.setValue(0) - fadeAnim.setValue(0) - Animated.timing(translateAnim, { - toValue: 1, - duration: 200, - useNativeDriver: true, - easing: Easing.out(Easing.ease), - }).start() - Animated.timing(fadeAnim, { - toValue: 1, - duration: 300, - easing: Easing.in(Easing.ease), - useNativeDriver: true, - }).start() - }, [fadeAnim, translateAnim, selectedDate]) - - const getIsLast = useCallback( - (index: number) => { - const length = moviesOffers.length ?? 0 - - return index === length - 1 - }, - [moviesOffers.length] - ) - - const getNextDateIndex = useCallback( - (nextDate: Date) => { - return nextFifteenDates.findIndex( - (date) => date.toISOString().split('T')[0] === nextDate.toISOString().split('T')[0] - ) - }, - [nextFifteenDates] - ) - const mapping = useCategoryIdMapping() const labelMapping = useCategoryHomeLabelMapping() @@ -145,47 +64,10 @@ export const MoviesScreeningCalendar: FunctionComponent = ({ venueOffers return ( - - - - - { - setWidth(nativeEvent.layout.width) - }} - style={{ - opacity: fadeAnim, - transform: [ - { translateX: Animated.subtract(Animated.multiply(translateAnim, width), width) }, - ], - }}> - {moviesOffers.map((movie, index) => ( - - ))} - - + + + {nonScreeningOffers.length > 0 ? ( = ({ venueOffers ) } -const Container = styled(View)(({ theme }) => ({ - marginHorizontal: theme.contentPage.marginHorizontal, -})) - const PlaylistTitleText = styled(TypoDS.Title3).attrs(getHeadingAttrs(2))`` diff --git a/src/features/offer/components/MoviesScreeningCalendar/NextScreeningButton.tsx b/src/features/offer/components/MoviesScreeningCalendar/NextScreeningButton.tsx index e9a88f0ed08..2fa99dbdc1d 100644 --- a/src/features/offer/components/MoviesScreeningCalendar/NextScreeningButton.tsx +++ b/src/features/offer/components/MoviesScreeningCalendar/NextScreeningButton.tsx @@ -10,6 +10,8 @@ import { PlainArrowNext } from 'ui/svg/icons/PlainArrowNext' type Props = { onPress: () => void; date: Date } +export const NEXT_SCREENING_WORDING = 'Prochaine séance\u00a0:' + export const NextScreeningButton: FC = ({ onPress, date }) => { const { dayDate, fullWeekDay, fullMonth } = extractDate(date) @@ -17,7 +19,7 @@ export const NextScreeningButton: FC = ({ onPress, date }) => { {'Prochaine séance\u00a0:'}} + message={{NEXT_SCREENING_WORDING}} backgroundColor={theme.colors.greyLight}> { + const { selectedDate } = useMovieCalendar() + const { data: offersWithStocks } = useOffersStocks({ offerIds }) + + const moviesOffers: MovieOffer[] = useMemo(() => { + const filteredOffersWithStocks = filterOffersStocksByDate( + offersWithStocks?.offers || [], + selectedDate + ) + const nextScreeningOffers = getNextMoviesByDate(offersWithStocks?.offers || [], selectedDate) + + return [...filteredOffersWithStocks, ...nextScreeningOffers] + }, [offersWithStocks?.offers, selectedDate]).filter( + (offer) => offer.offer.subcategoryId === SubcategoryIdEnum.SEANCE_CINE + ) + + return { + moviesOffers, + } +} + +export const VenueCalendar: FunctionComponent = ({ venueOffers, offerIds }) => { + const { moviesOffers } = useMoviesScreeningsList(offerIds) + + const getIsLast = useCallback( + (index: number) => { + const length = moviesOffers.length ?? 0 + return index === length - 1 + }, + [moviesOffers.length] + ) + + return ( + + + {moviesOffers.map((movie, index) => ( + + ))} + + ) +} + +const Container = styled(View)(({ theme }) => ({ + marginHorizontal: theme.contentPage.marginHorizontal, +})) diff --git a/src/features/offer/components/MoviesScreeningCalendar/moviesOffer.builder.ts b/src/features/offer/components/MoviesScreeningCalendar/moviesOffer.builder.ts index 013325c1e3a..9591fc7f6a5 100644 --- a/src/features/offer/components/MoviesScreeningCalendar/moviesOffer.builder.ts +++ b/src/features/offer/components/MoviesScreeningCalendar/moviesOffer.builder.ts @@ -1,4 +1,11 @@ -import { addDays, differenceInMilliseconds, isAfter, isBefore, isSameDay } from 'date-fns' +import { + addDays, + differenceInMilliseconds, + isAfter, + isBefore, + isSameDay, + startOfDay, +} from 'date-fns' import { OfferResponseV2, OfferStockResponse } from 'api/gen' import { MovieOffer } from 'features/offer/components/MoviesScreeningCalendar/getNextMoviesByDate' @@ -154,7 +161,7 @@ export const moviesOfferBuilder = (offersWithStocks: OfferResponseV2[] = []) => } const isDateNotWithinNext15Days = (referenceDate: Date, targetDate: Date) => { - const datePlus15Days = addDays(referenceDate, 15) + const datePlus15Days = addDays(startOfDay(referenceDate), 15) return isAfter(targetDate, datePlus15Days) } diff --git a/src/features/offer/components/MoviesScreeningCalendar/offersStockResponse.builder.ts b/src/features/offer/components/MoviesScreeningCalendar/offersStockResponse.builder.ts index 3f7c1cc88cf..04b0e4c24bc 100644 --- a/src/features/offer/components/MoviesScreeningCalendar/offersStockResponse.builder.ts +++ b/src/features/offer/components/MoviesScreeningCalendar/offersStockResponse.builder.ts @@ -1,4 +1,4 @@ -import { OfferResponseV2, OfferStockResponse } from 'api/gen' +import { OfferResponseV2, OfferStockResponse, OfferVenueResponse } from 'api/gen' import { createBuilder, createDateBuilder, @@ -9,5 +9,6 @@ const defaultOfferResponse = offersStocksResponseSnap.offers[0] const defaultStock = defaultOfferResponse.stocks[0] export const offerResponseBuilder = createBuilder(defaultOfferResponse) +export const venueBuilder = createBuilder(defaultOfferResponse.venue) export const stockBuilder = createBuilder(defaultStock) export const dateBuilder = createDateBuilder() diff --git a/src/features/offer/components/OfferBody/OfferBody.native.test.tsx b/src/features/offer/components/OfferBody/OfferBody.native.test.tsx index 78eccf5d3e3..78bbd845f51 100644 --- a/src/features/offer/components/OfferBody/OfferBody.native.test.tsx +++ b/src/features/offer/components/OfferBody/OfferBody.native.test.tsx @@ -85,6 +85,17 @@ jest.mock('@batch.com/react-native-plugin', () => jest.requireActual('__mocks__/libs/react-native-batch') ) +jest.mock('ui/components/anchor/AnchorContext', () => ({ + useScrollToAnchor: jest.fn, + useRegisterAnchor: jest.fn, +})) + +jest.mock('react-native/Libraries/Animated/createAnimatedComponent', () => { + return function createAnimatedComponent(Component: unknown) { + return Component + } +}) + const useArtistResultsSpy = jest .spyOn(useArtistResults, 'useArtistResults') .mockImplementation() diff --git a/src/features/offer/components/OfferContent/OfferContentBase.tsx b/src/features/offer/components/OfferContent/OfferContentBase.tsx index ce724d2b4d8..13a0db6f8c2 100644 --- a/src/features/offer/components/OfferContent/OfferContentBase.tsx +++ b/src/features/offer/components/OfferContent/OfferContentBase.tsx @@ -1,5 +1,18 @@ -import React, { FunctionComponent, ReactElement, useCallback, useEffect, useMemo } from 'react' -import { NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native' +import React, { + FunctionComponent, + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react' +import { + NativeScrollEvent, + NativeSyntheticEvent, + StyleProp, + ViewStyle, + ScrollView, +} from 'react-native' import { IOScrollView as IntersectionObserverScrollView } from 'react-native-intersection-observer' import styled from 'styled-components/native' @@ -15,6 +28,7 @@ import { OfferContentProps } from 'features/offer/types' import { analytics, isCloseToBottom } from 'libs/analytics' import { useFunctionOnce } from 'libs/hooks' import { useOpacityTransition } from 'ui/animations/helpers/useOpacityTransition' +import { AnchorProvider } from 'ui/components/anchor/AnchorContext' type OfferContentBaseProps = OfferContentProps & { BodyWrapper: FunctionComponent @@ -40,6 +54,8 @@ export const OfferContentBase: FunctionComponent = ({ otherCategoriesSimilarOffers, apiRecoParamsOtherCategories, } = useOfferPlaylist({ offer, offerSearchGroup: subcategory.searchGroupName, searchGroupList }) + const scrollViewRef = useRef(null) + const scrollYRef = useRef(0) const logConsultWholeOffer = useFunctionOnce(() => { analytics.logConsultWholeOffer(offer.id) @@ -82,37 +98,52 @@ export const OfferContentBase: FunctionComponent = ({ listener: scrollEventListener, }) + const handleCheckScrollY = () => { + return scrollYRef.current + } + + const handleScroll = useCallback( + (event: NativeSyntheticEvent) => { + onScroll(event) + scrollYRef.current = event.nativeEvent.contentOffset.y + }, + [onScroll] + ) + return ( - - - 0 ? onOfferPreviewPress : undefined} - /> - + + + 0 ? onOfferPreviewPress : undefined} + /> + + + - - - + + {footer} ) diff --git a/src/features/offer/components/OfferEventCardList/OfferEventCardList.tsx b/src/features/offer/components/OfferEventCardList/OfferEventCardList.tsx index 1024f7e2503..49caa3513a6 100644 --- a/src/features/offer/components/OfferEventCardList/OfferEventCardList.tsx +++ b/src/features/offer/components/OfferEventCardList/OfferEventCardList.tsx @@ -1,4 +1,5 @@ import React, { FC, useMemo } from 'react' +import { View } from 'react-native' import { OfferResponseV2 } from 'api/gen' import { useMovieScreeningCalendar } from 'features/offer/components/MovieScreeningCalendar/useMovieScreeningCalendar' @@ -37,9 +38,9 @@ export const OfferEventCardList: FC = ({ offer, selectedDate = new Date() ) return ( - + {eventCardData ? : null} {CTAOfferModal} - + ) } diff --git a/src/features/offer/components/OfferNewXPCine/CineBlock.native.test.tsx b/src/features/offer/components/OfferNewXPCine/CineBlock.native.test.tsx new file mode 100644 index 00000000000..d91a7ccb104 --- /dev/null +++ b/src/features/offer/components/OfferNewXPCine/CineBlock.native.test.tsx @@ -0,0 +1,88 @@ +import React from 'react' + +import * as MovieCalendarContext from 'features/offer/components/MoviesScreeningCalendar/MovieCalendarContext' +import { NEXT_SCREENING_WORDING } from 'features/offer/components/MoviesScreeningCalendar/NextScreeningButton' +import { + offerResponseBuilder, + venueBuilder, +} from 'features/offer/components/MoviesScreeningCalendar/offersStockResponse.builder' +import { reactQueryProviderHOC } from 'tests/reactQueryProviderHOC' +import { fireEvent, render, screen } from 'tests/utils' + +import { CineBlock, CineBlockProps } from './CineBlock' + +jest.mock('features/offer/components/MoviesScreeningCalendar/MovieCalendarContext', () => ({ + useMovieCalendar: jest.fn(), +})) + +jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter') +jest.mock('libs/firebase/analytics/analytics') +jest.mock('@batch.com/react-native-plugin', () => + jest.requireActual('__mocks__/libs/react-native-batch') +) + +jest.mock('react-native-safe-area-context', () => ({ + ...(jest.requireActual('react-native-safe-area-context') as Record), + useSafeAreaInsets: () => ({ bottom: 16, right: 16, left: 16, top: 16 }), +})) + +const mockOfferTitle = 'CINEMA DE LA RUE' +const mockOfferVenue = venueBuilder().withName(mockOfferTitle).build() +const mockOffer = offerResponseBuilder().withVenue(mockOfferVenue).build() + +const mockSelectedDate = new Date('2023-05-01') +const mockGoToDate = jest.fn() + +describe('CineBlock', () => { + beforeEach(() => { + jest.spyOn(MovieCalendarContext, 'useMovieCalendar').mockReturnValue({ + selectedDate: mockSelectedDate, + goToDate: mockGoToDate, + }) + }) + + it('should render VenueBlock', () => { + renderCineBlock({ offer: mockOffer }) + + expect(screen.getByText(mockOfferTitle)).toBeOnTheScreen() + }) + + it('should render NextScreeningButton when nextDate is provided', async () => { + const nextDate = new Date('2023-05-02') + renderCineBlock({ nextDate }) + + expect(await screen.findByText(NEXT_SCREENING_WORDING)).toBeOnTheScreen() + }) + + it('should render OfferEventCardList when nextDate is not provided', () => { + renderCineBlock({}) + + expect(screen.getByTestId('offer-event-card-list')).toBeOnTheScreen() + }) + + it('should call onSeeVenuePress when provided', () => { + const mockOnSeeVenuePress = jest.fn() + renderCineBlock({ onSeeVenuePress: mockOnSeeVenuePress }) + const seeVenueButton = screen.getByText(mockOfferTitle) + + fireEvent.press(seeVenueButton) + + expect(mockOnSeeVenuePress).toHaveBeenCalledTimes(1) + }) + + it('should call goToDate when NextScreeningButton is pressed', async () => { + const nextDate = new Date('2023-05-02') + renderCineBlock({ nextDate }) + const nextScreeningButton = await screen.findByText(NEXT_SCREENING_WORDING) + + fireEvent.press(nextScreeningButton) + + expect(mockGoToDate).toHaveBeenCalledWith(nextDate) + }) +}) + +const renderCineBlock = (props: Partial) => { + return render(, { + wrapper: ({ children }) => reactQueryProviderHOC(children), + }) +} diff --git a/src/features/offer/components/OfferNewXPCine/CineBlock.tsx b/src/features/offer/components/OfferNewXPCine/CineBlock.tsx index 6bdd86fb074..d33c56b8239 100644 --- a/src/features/offer/components/OfferNewXPCine/CineBlock.tsx +++ b/src/features/offer/components/OfferNewXPCine/CineBlock.tsx @@ -3,22 +3,34 @@ import { View } from 'react-native' import styled from 'styled-components/native' import { OfferResponseV2 } from 'api/gen' +import { useMovieCalendar } from 'features/offer/components/MoviesScreeningCalendar/MovieCalendarContext' +import { NextScreeningButton } from 'features/offer/components/MoviesScreeningCalendar/NextScreeningButton' import { OfferEventCardList } from 'features/offer/components/OfferEventCardList/OfferEventCardList' import { VenueBlock } from 'features/offer/components/OfferVenueBlock/VenueBlock' import { Spacer } from 'ui/theme' -type Props = { +export type CineBlockProps = { offer: OfferResponseV2 - selectedDate: Date onSeeVenuePress?: VoidFunction + nextDate?: Date } -export const CineBlock: FunctionComponent = ({ offer, onSeeVenuePress, selectedDate }) => { +export const CineBlock: FunctionComponent = ({ + offer, + onSeeVenuePress, + nextDate, +}) => { + const { selectedDate, goToDate } = useMovieCalendar() + return ( - + {nextDate ? ( + goToDate(nextDate)} /> + ) : ( + + )} ) } diff --git a/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.native.test.tsx b/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.native.test.tsx index 5099e16a9ed..534d1e95f98 100644 --- a/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.native.test.tsx +++ b/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.native.test.tsx @@ -20,6 +20,22 @@ jest.mock('libs/location/LocationWrapper', () => ({ }), })) +jest.mock('ui/components/anchor/AnchorContext', () => ({ + useScrollToAnchor: jest.fn, + useRegisterAnchor: jest.fn, +})) + +jest.mock('react-native/Libraries/Animated/createAnimatedComponent', () => { + return function createAnimatedComponent(Component: unknown) { + return Component + } +}) + +jest.mock('react-native-safe-area-context', () => ({ + ...(jest.requireActual('react-native-safe-area-context') as Record), + useSafeAreaInsets: () => ({ bottom: 16, right: 16, left: 16, top: 16 }), +})) + jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter') jest.mock('@batch.com/react-native-plugin', () => @@ -35,9 +51,7 @@ const useGetVenueByDayReturn: ReturnType<(typeof useGetVenuesByDayModule)['useGe isEnd: false, } -const spyUseGetVenuesByDay = jest - .spyOn(useGetVenuesByDayModule, 'useGetVenuesByDay') - .mockReturnValue(useGetVenueByDayReturn) +const spyUseGetVenuesByDay = jest.spyOn(useGetVenuesByDayModule, 'useGetVenuesByDay') describe('OfferNewXPCineBlock', () => { it('should display skeleton when data is loading', () => { @@ -48,7 +62,7 @@ describe('OfferNewXPCineBlock', () => { expect(screen.getByTestId('cine-block-skeleton')).toBeOnTheScreen() }) - it('should not display skeleton when data is loaded', () => { + it('should not display skeleton when data is loaded', async () => { spyUseGetVenuesByDay.mockReturnValueOnce({ ...useGetVenueByDayReturn, isLoading: false }) render() diff --git a/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.tsx b/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.tsx index 27390bfb2ce..343e4a85dcf 100644 --- a/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.tsx +++ b/src/features/offer/components/OfferNewXPCine/OfferNewXPCineBlock.tsx @@ -1,21 +1,14 @@ -import React, { FC, useEffect, useRef, useCallback, useState } from 'react' -import { FlatList, View } from 'react-native' -import Animated, { useAnimatedStyle, withTiming, Easing } from 'react-native-reanimated' +import React, { FC } from 'react' +import { View, ViewStyle } from 'react-native' import styled, { useTheme } from 'styled-components/native' import { OfferResponseV2 } from 'api/gen' -import { MovieCalendar } from 'features/offer/components/MovieCalendar/MovieCalendar' -import { CineBlock } from 'features/offer/components/OfferNewXPCine/CineBlock' -import { CineBlockSkeleton } from 'features/offer/components/OfferNewXPCine/CineBlockSkeleton' -import { useGetVenuesByDay } from 'features/offer/helpers/useGetVenueByDay/useGetVenuesByDay' -import { useNextDays } from 'features/offer/helpers/useNextDays/useNextDays' -import { ButtonSecondary } from 'ui/components/buttons/ButtonSecondary' -import { PlainMore } from 'ui/svg/icons/PlainMore' +import { MovieCalendarProvider } from 'features/offer/components/MoviesScreeningCalendar/MovieCalendarContext' +import { OfferNewXPCineContent } from 'features/offer/components/OfferNewXPCine/OfferNewXPCineContent' +import { AppThemeType } from 'theme' import { getSpacing, Spacer, TypoDS } from 'ui/theme' import { getHeadingAttrs } from 'ui/theme/typographyAttrs/getHeadingAttrs' -const ANIMATION_DURATION = 300 - type Props = { title: string offer: OfferResponseV2 @@ -24,124 +17,28 @@ type Props = { export const OfferNewXPCineBlock: FC = ({ title, onSeeVenuePress, offer }) => { const theme = useTheme() - const flatListRef = useRef(null) - const { selectedDate, setSelectedDate, dates } = useNextDays(15) - const { - increaseCount, - isEnd: hasReachedVenueListEnd, - items, - isLoading, - } = useGetVenuesByDay(selectedDate, offer, { initialCount: 6, nextCount: 3, radiusKm: 50 }) - - const { animatedStyle, onContentSizeChange } = useAnimatedHeight() - - useEffect(() => { - if (flatListRef?.current) { - flatListRef.current?.scrollToOffset({ offset: 0 }) - } - }, [flatListRef]) return ( {title} - - - - - - - - {isLoading ? : null} - ( - - - - - - )} - /> - {hasReachedVenueListEnd ? null : ( - - - Aucune séance ne te correspond ? - - - - )} - + + + ) } -const useAnimatedHeight = () => { - const [contentHeight, setContentHeight] = useState(0) - const isFirstRender = useRef(true) - - const animatedStyle = useAnimatedStyle(() => { - if (isFirstRender.current) { - isFirstRender.current = false - return { height: contentHeight } - } - - return { - height: withTiming(contentHeight, { - duration: ANIMATION_DURATION, - easing: Easing.bezier(0.25, 0.1, 0.25, 1), - }), - } - }, [contentHeight]) - - const onContentSizeChange = useCallback((_width: number, height: number) => { - setContentHeight(height) - }, []) - - return { animatedStyle, onContentSizeChange } -} +const getCalendarStyle = (theme: AppThemeType): ViewStyle => ({ + marginRight: theme.isDesktopViewport ? -getSpacing(16) : 0, +}) const Container = styled(View)({ marginVertical: 0, }) -const MovieCalendarContainer = styled(View)(({ theme }) => ({ - marginRight: theme.isDesktopViewport ? -getSpacing(16) : 0, // cancels padding of the parent container -})) - -const TitleContainer = styled(View)(({ theme }) => ({ - marginHorizontal: theme.isDesktopViewport ? undefined : theme.contentPage.marginHorizontal, -})) - -const Divider = styled.View(({ theme }) => ({ - height: 1, - backgroundColor: theme.colors.greyMedium, +const TitleContainer = styled.View(({ theme }) => ({ marginHorizontal: theme.isDesktopViewport ? undefined : theme.contentPage.marginHorizontal, })) - -const SeeMoreContainer = styled.View(({ theme }) => ({ - alignItems: theme.isMobileViewport ? 'center' : undefined, -})) - -const Text = styled(TypoDS.Body)(({ theme }) => ({ - color: theme.colors.greyDark, -})) diff --git a/src/features/offer/components/OfferNewXPCine/OfferNewXPCineContent.tsx b/src/features/offer/components/OfferNewXPCine/OfferNewXPCineContent.tsx new file mode 100644 index 00000000000..3b6f1d3990d --- /dev/null +++ b/src/features/offer/components/OfferNewXPCine/OfferNewXPCineContent.tsx @@ -0,0 +1,105 @@ +import React, { FC, useRef, useCallback, useState } from 'react' +import { View } from 'react-native' +import Animated, { useAnimatedStyle, withTiming, Easing } from 'react-native-reanimated' +import styled, { useTheme } from 'styled-components/native' + +import { OfferResponseV2 } from 'api/gen' +import { useMovieCalendar } from 'features/offer/components/MoviesScreeningCalendar/MovieCalendarContext' +import { CineBlock } from 'features/offer/components/OfferNewXPCine/CineBlock' +import { CineBlockSkeleton } from 'features/offer/components/OfferNewXPCine/CineBlockSkeleton' +import { useGetVenuesByDay } from 'features/offer/helpers/useGetVenueByDay/useGetVenuesByDay' +import { ButtonSecondary } from 'ui/components/buttons/ButtonSecondary' +import { PlainMore } from 'ui/svg/icons/PlainMore' +import { Spacer, TypoDS } from 'ui/theme' + +const ANIMATION_DURATION = 300 + +export const OfferNewXPCineContent: FC<{ + offer: OfferResponseV2 + onSeeVenuePress?: VoidFunction +}> = ({ offer, onSeeVenuePress }) => { + const theme = useTheme() + const { animatedStyle, onContentSizeChange } = useAnimatedHeight() + const { selectedDate } = useMovieCalendar() + const { + increaseCount, + isEnd: hasReachedVenueListEnd, + items, + isLoading, + } = useGetVenuesByDay(selectedDate, offer, { initialCount: 6, nextCount: 3, radiusKm: 50 }) + + return ( + + {isLoading ? : null} + ( + + + + + + )} + /> + {hasReachedVenueListEnd ? null : ( + + + Aucune séance ne te correspond ? + + + + )} + + ) +} + +const useAnimatedHeight = () => { + const [contentHeight, setContentHeight] = useState(0) + const isFirstRender = useRef(true) + + const animatedStyle = useAnimatedStyle(() => { + if (isFirstRender.current) { + isFirstRender.current = false + return { height: contentHeight } + } + + return { + height: withTiming(contentHeight, { + duration: ANIMATION_DURATION, + easing: Easing.bezier(0.25, 0.1, 0.25, 1), + }), + } + }, [contentHeight]) + + const onContentSizeChange = useCallback((_width: number, height: number) => { + setContentHeight(height) + }, []) + + return { animatedStyle, onContentSizeChange } +} + +const Divider = styled.View(({ theme }) => ({ + height: 1, + backgroundColor: theme.colors.greyMedium, + marginHorizontal: theme.isDesktopViewport ? undefined : theme.contentPage.marginHorizontal, +})) + +const SeeMoreContainer = styled.View(({ theme }) => ({ + alignItems: theme.isMobileViewport ? 'center' : undefined, +})) + +const Text = styled(TypoDS.Body)(({ theme }) => ({ + color: theme.colors.greyDark, +})) diff --git a/src/features/offer/components/OfferPlace/OfferPlace.native.test.tsx b/src/features/offer/components/OfferPlace/OfferPlace.native.test.tsx index 4e1231c4dfa..085f2d7a3d3 100644 --- a/src/features/offer/components/OfferPlace/OfferPlace.native.test.tsx +++ b/src/features/offer/components/OfferPlace/OfferPlace.native.test.tsx @@ -12,6 +12,7 @@ import { ILocationContext, LocationMode } from 'libs/location/types' import { SuggestedPlace } from 'libs/place/types' import { reactQueryProviderHOC } from 'tests/reactQueryProviderHOC' import { act, fireEvent, render, screen } from 'tests/utils' +import { AnchorProvider } from 'ui/components/anchor/AnchorContext' import * as useModalAPI from 'ui/components/modals/useModal' jest.mock('libs/address/useFormatFullAddress') @@ -629,4 +630,5 @@ const renderOfferPlace = ({ }: RenderOfferPlaceType) => render(reactQueryProviderHOC(), { theme: { isDesktopViewport: isDesktopViewport ?? false }, + wrapper: AnchorProvider, }) diff --git a/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.native.test.ts b/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.native.test.ts index 2513e9e1905..8c9e7be0a25 100644 --- a/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.native.test.ts +++ b/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.native.test.ts @@ -82,7 +82,9 @@ describe('useGetVenueByDay', () => { await act(async () => {}) - expect(result.current.items).toHaveLength(1) + const filteredItems = result.current.items.filter((item) => !item.nextDate) + + expect(filteredItems).toHaveLength(1) }) it('should return the specified initial number of cinema', async () => { @@ -185,7 +187,9 @@ describe('useGetVenueByDay', () => { rerender({ date: TOMORROW.toDate() }) }) - expect(result.current.items).toHaveLength(5) + const filteredItems = result.current.items.filter((item) => !item.nextDate) + + expect(filteredItems).toHaveLength(5) }) it('should return the initial number of cinema after using increaseCount', async () => { diff --git a/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.ts b/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.ts index 62244cd4391..e1ad8e869b2 100644 --- a/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.ts +++ b/src/features/offer/helpers/useGetVenueByDay/useGetVenuesByDay.ts @@ -7,7 +7,6 @@ import { useOffersStocks } from 'features/offer/api/useOffersStocks' import { moviesOfferBuilder } from 'features/offer/components/MoviesScreeningCalendar/moviesOffer.builder' import { useIsUserUnderage } from 'features/profile/helpers/useIsUserUnderage' import { initialSearchState } from 'features/search/context/reducer' -import { DATE_FILTER_OPTIONS } from 'features/search/enums' import { useLocation } from 'libs/location' import { LocationMode } from 'libs/location/types' import { Offer } from 'shared/offer/types' @@ -39,7 +38,6 @@ export const useGetVenuesByDay = (date: Date, offer: OfferResponseV2, options?: parameters: { ...initialSearchState, allocineId: offer.extraData?.allocineId ?? undefined, - date: { selectedDate: date.toISOString(), option: DATE_FILTER_OPTIONS.USER_PICK }, distinct: false, }, buildLocationParameterParams: { @@ -58,32 +56,47 @@ export const useGetVenuesByDay = (date: Date, offer: OfferResponseV2, options?: setCount(initialCount) }, [date, initialCount]) - const filteredOffers = useMemo( + const dayOffers = useMemo( () => moviesOfferBuilder(offersWithStocks?.offers) - .withMoviesOnDay(date) + .withoutMoviesAfter15Days() .sortedByDistance(location) - .buildOfferResponse(), + .withMoviesOnDay(date) + .build(), [date, location, offersWithStocks?.offers] ) - const displayOffers = filteredOffers.slice(0, count) + const nextOffers = useMemo(() => { + return moviesOfferBuilder(offersWithStocks?.offers) + .withoutMoviesAfter15Days() + .sortedByDistance(location) + .withoutMoviesOnDay(date) + .withNextScreeningFromDate(date) + .build() + }, [date, location, offersWithStocks?.offers]) + + const movieOffers = useMemo(() => [...dayOffers, ...nextOffers], [dayOffers, nextOffers]) + + const displayedOffers = useMemo( + () => (count === movieOffers.length ? movieOffers : movieOffers.slice(0, count)), + [count, movieOffers] + ) const increaseCount = useCallback( () => setCount((count) => { const newCount = count + nextCount - return Math.max(newCount, filteredOffers.length) + return Math.max(newCount, displayedOffers.length) }), - [filteredOffers.length, nextCount] + [displayedOffers.length, nextCount] ) const isEnd = useMemo(() => { - return displayOffers.length === filteredOffers.length - }, [displayOffers.length, filteredOffers.length]) + return displayedOffers.length === dayOffers.length + nextOffers.length + }, [displayedOffers.length, dayOffers.length, nextOffers.length]) return { - items: displayOffers, + items: displayedOffers, isLoading, increaseCount, isEnd, diff --git a/src/features/venue/components/VenueContent/VenueContent.tsx b/src/features/venue/components/VenueContent/VenueContent.tsx index 22a895796f1..2743f3bc98f 100644 --- a/src/features/venue/components/VenueContent/VenueContent.tsx +++ b/src/features/venue/components/VenueContent/VenueContent.tsx @@ -116,7 +116,10 @@ export const VenueContent: React.FunctionComponent = ({ {/* On web VenueHeader is called before Body for accessibility navigate order */} {isWeb ? : null} - + handleCheckScrollY(): number children: React.ReactNode + offset?: number } export const AnchorProvider = ({ scrollViewRef, handleCheckScrollY, + offset = 0, children, }: AnchorProviderProps) => { const { top } = useSafeAreaInsets() + const anchors = useRef>>>({}) const registerAnchor = useCallback((name: AnchorName, ref: RefObject) => { @@ -44,14 +47,14 @@ export const AnchorProvider = ({ ) => { const currentPageScroll = handleCheckScrollY() scrollViewRef.current?.scrollTo({ - y: pageY + currentPageScroll - height - top, + y: pageY + currentPageScroll - height - top - offset, animated: true, }) } ) } }, - [handleCheckScrollY, scrollViewRef, top] + [handleCheckScrollY, offset, scrollViewRef, top] ) const value = useMemo( diff --git a/src/ui/components/anchor/anchor-name.ts b/src/ui/components/anchor/anchor-name.ts index 44a8819a741..e9699fdd61e 100644 --- a/src/ui/components/anchor/anchor-name.ts +++ b/src/ui/components/anchor/anchor-name.ts @@ -1 +1 @@ -export type AnchorName = 'venue-calendar' +export type AnchorName = 'movie-calendar'