diff --git a/__snapshots__/features/home/pages/Home.native.test.tsx.native-snap b/__snapshots__/features/home/pages/Home.native.test.tsx.native-snap index d671533a4cc..0659e63a3af 100644 --- a/__snapshots__/features/home/pages/Home.native.test.tsx.native-snap +++ b/__snapshots__/features/home/pages/Home.native.test.tsx.native-snap @@ -373,7 +373,7 @@ exports[`Home page should render correctly 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/__snapshots__/features/profile/pages/Profile.native.test.tsx.native-snap b/__snapshots__/features/profile/pages/Profile.native.test.tsx.native-snap index fe6ab0cff69..81d9a1cf55e 100644 --- a/__snapshots__/features/profile/pages/Profile.native.test.tsx.native-snap +++ b/__snapshots__/features/profile/pages/Profile.native.test.tsx.native-snap @@ -1765,7 +1765,7 @@ exports[`Profile component should render correctly 1`] = ` { "busy": undefined, "checked": undefined, - "disabled": undefined, + "disabled": false, "expanded": undefined, "selected": undefined, } diff --git a/src/cheatcodes/pages/CheatcodesMenu.tsx b/src/cheatcodes/pages/CheatcodesMenu.tsx index 3872260b8c0..b50f59b5174 100644 --- a/src/cheatcodes/pages/CheatcodesMenu.tsx +++ b/src/cheatcodes/pages/CheatcodesMenu.tsx @@ -54,6 +54,7 @@ export function CheatcodesMenu(): React.JSX.Element { ...cheatcodesNavigationTutorialButtons, ...cheatcodesNavigationForceUpdateButtons, { title: 'Share 🔗', screen: 'CheatcodesNavigationShare', subscreens: [] }, + { title: 'RemoteBanner 🆒', screen: 'CheatcodesScreenRemoteBanner', subscreens: [] }, ] const otherButtons: CheatcodesButtonsWithSubscreensProps[] = [ diff --git a/src/cheatcodes/pages/features/remoteBanner/CheatcodesScreenRemoteBanner.tsx b/src/cheatcodes/pages/features/remoteBanner/CheatcodesScreenRemoteBanner.tsx new file mode 100644 index 00000000000..5e94d01f561 --- /dev/null +++ b/src/cheatcodes/pages/features/remoteBanner/CheatcodesScreenRemoteBanner.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useState } from 'react' + +import { CheatcodesTemplateScreen } from 'cheatcodes/components/CheatcodesTemplateScreen' +import { RemoteBanner } from 'features/remoteBanner/components/RemoteBanner' +import { remoteBannerSchema } from 'features/remoteBanner/components/remoteBannerSchema' +import { useFeatureFlagOptions } from 'libs/firebase/firestore/featureFlags/useFeatureFlagOptions' +import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' +import { ErrorBanner } from 'ui/components/banners/ErrorBanner' +import { ViewGap } from 'ui/components/ViewGap/ViewGap' +import { getSpacing } from 'ui/theme' + +export const CheatcodesScreenRemoteBanner = () => { + const { options } = useFeatureFlagOptions(RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER) + const [error, setError] = useState('') + + useEffect(() => { + try { + remoteBannerSchema.validateSync(options) + } catch (error) { + setError(String(error)) + } + }, [options]) + + return ( + + + + {error ? ( + + ) : null} + + + ) +} diff --git a/src/features/forceUpdate/components/ForceUpdateBanner.tsx b/src/features/forceUpdate/components/ForceUpdateBanner.tsx deleted file mode 100644 index ddc80dd5589..00000000000 --- a/src/features/forceUpdate/components/ForceUpdateBanner.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react' -import styled from 'styled-components/native' - -import { TITLE, BUTTON_TEXT_BANNER } from 'features/forceUpdate/constants' -import { onPressStoreLink } from 'features/forceUpdate/helpers/onPressStoreLink' -import { BannerWithBackground } from 'ui/components/ModuleBanner/BannerWithBackground' -import { ArrowAgain } from 'ui/svg/icons/ArrowAgain' -import { TypoDS } from 'ui/theme' - -export const ForceUpdateBanner = () => ( - - {TITLE} - {BUTTON_TEXT_BANNER} - -) - -const StyledButtonText = styled(TypoDS.Button)(({ theme }) => ({ - color: theme.colors.white, -})) - -const StyledBodyText = styled(TypoDS.Body)(({ theme }) => ({ - color: theme.colors.white, -})) diff --git a/src/features/forceUpdate/constants.ts b/src/features/forceUpdate/constants.ts index e580cbf28d4..c2259a32ae1 100644 --- a/src/features/forceUpdate/constants.ts +++ b/src/features/forceUpdate/constants.ts @@ -23,11 +23,6 @@ export const DESCRIPTION = Platform.select({ Pour des questions de performance et de sécurité merci d’actualiser la page pour obtenir la dernière version disponible.`, }) -export const BUTTON_TEXT_BANNER = Platform.select({ - default: 'Télécharger la dernière version de l’application', - web: 'Actualiser la page', -}) - export const BUTTON_TEXT_SCREEN = Platform.select({ default: 'Télécharger la dernière version', web: 'Actualiser la page', diff --git a/src/features/home/components/modules/banners/HomeBanner.native.test.tsx b/src/features/home/components/modules/banners/HomeBanner.native.test.tsx index da4366ff37c..2b35f6b8100 100644 --- a/src/features/home/components/modules/banners/HomeBanner.native.test.tsx +++ b/src/features/home/components/modules/banners/HomeBanner.native.test.tsx @@ -26,26 +26,46 @@ const mockUseGeolocation = jest.mocked(useLocation) jest.mock('shared/user/useGetDepositAmountsByAge') const mockDepositAmounts = jest.mocked(useGetDepositAmountsByAge) +jest.mock('@react-native-firebase/firestore') + +jest.useFakeTimers() + describe('', () => { beforeEach(() => { setFeatureFlags() }) - it('should display force update banner when feature flag showForceUpdateBanner is enable', async () => { - setFeatureFlags([RemoteStoreFeatureFlags.SHOW_FORCE_UPDATE_BANNER]) - mockSubscriptionStepper() - mockBannerFromBackend({ - banner: { - name: BannerName.retry_identity_check_banner, - title: 'Retente ubble', - text: 'pour débloquer ton crédit', - }, + describe('when feature flag showRemoteBanner is enable', () => { + beforeEach(() => { + setFeatureFlags([ + { + featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, + options: { + title: 'title 1', + subtitleMobile: 'subtitleMobile 1', + subtitleWeb: 'subtitleWeb 1', + redirectionUrl: 'https://www.test.fr', + redirectionType: 'store', + }, + }, + ]) }) - renderHomeBanner({}) - await act(async () => {}) + it('should display force update banner', async () => { + mockSubscriptionStepper() + mockBannerFromBackend({ + banner: { + name: BannerName.retry_identity_check_banner, + title: 'Retente ubble', + text: 'pour débloquer ton crédit', + }, + }) + renderHomeBanner({}) - expect(screen.getByText('Mise à jour requise !')).toBeOnTheScreen() + const banner = await screen.findByText('title 1') + + expect(banner).toBeOnTheScreen() + }) }) describe('When wipAppV2SystemBlock feature flag deactivated', () => { @@ -124,7 +144,7 @@ describe('', () => { }) describe('When wipAppV2SystemBlock feature flag activated', () => { - beforeAll(() => { + beforeEach(() => { setFeatureFlags([RemoteStoreFeatureFlags.WIP_APP_V2_SYSTEM_BLOCK]) }) diff --git a/src/features/home/components/modules/banners/HomeBanner.tsx b/src/features/home/components/modules/banners/HomeBanner.tsx index 9ded335c014..a2969e42d34 100644 --- a/src/features/home/components/modules/banners/HomeBanner.tsx +++ b/src/features/home/components/modules/banners/HomeBanner.tsx @@ -3,11 +3,11 @@ import React, { ComponentType, FunctionComponent, useCallback, useMemo } from 'r import styled from 'styled-components/native' import { BannerName } from 'api/gen' -import { ForceUpdateBanner } from 'features/forceUpdate/components/ForceUpdateBanner' import { useActivationBanner } from 'features/home/api/useActivationBanner' import { ActivationBanner } from 'features/home/components/banners/ActivationBanner' import { SignupBanner } from 'features/home/components/banners/SignupBanner' import { StepperOrigin, UseNavigationType } from 'features/navigation/RootNavigator/types' +import { RemoteBanner } from 'features/remoteBanner/components/RemoteBanner' import { useFeatureFlag } from 'libs/firebase/firestore/featureFlags/useFeatureFlag' import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' import { SystemBanner as GenericSystemBanner } from 'ui/components/ModuleBanner/SystemBanner' @@ -54,7 +54,7 @@ const bannersToRender = [ ] export const HomeBanner = ({ isLoggedIn }: HomeBannerProps) => { - const showForceUpdateBanner = useFeatureFlag(RemoteStoreFeatureFlags.SHOW_FORCE_UPDATE_BANNER) + const showRemoteBanner = useFeatureFlag(RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER) const { banner } = useActivationBanner() const { navigate } = useNavigation() const enableSystemBanner = useFeatureFlag(RemoteStoreFeatureFlags.WIP_APP_V2_SYSTEM_BLOCK) @@ -138,10 +138,10 @@ export const HomeBanner = ({ isLoggedIn }: HomeBannerProps) => { return null }, [isLoggedIn, shouldRenderSystemBanner, renderSystemBanner, banner, enableSystemBanner]) - if (showForceUpdateBanner) { + if (showRemoteBanner) { return ( - + ) } diff --git a/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigator.tsx b/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigator.tsx index 2051c0781a5..567e4b562e5 100644 --- a/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigator.tsx +++ b/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigator.tsx @@ -14,6 +14,7 @@ import { CheatcodesNavigationIdentityCheck } from 'cheatcodes/pages/features/ide import { CheatcodesNavigationNewIdentificationFlow } from 'cheatcodes/pages/features/identityCheck/CheatcodesNavigationNewIdentificationFlow' import { CheatcodesNavigationInternal } from 'cheatcodes/pages/features/internal/CheatcodesNavigationInternal' import { CheatcodesNavigationProfile } from 'cheatcodes/pages/features/profile/CheatcodesNavigationProfile' +import { CheatcodesScreenRemoteBanner } from 'cheatcodes/pages/features/remoteBanner/CheatcodesScreenRemoteBanner' import { CheatcodesNavigationShare } from 'cheatcodes/pages/features/share/CheatcodesNavigationShare' import { CheatcodesNavigationSubscription } from 'cheatcodes/pages/features/subscription/CheatcodesNavigationSubscription' import { CheatcodesNavigationTrustedDevice } from 'cheatcodes/pages/features/trustedDevice/CheatcodesNavigationTrustedDevice' @@ -137,6 +138,10 @@ const routes: CheatcodesStackRoute[] = [ name: 'CheatcodesScreenNewCaledonia', component: CheatcodesScreenNewCaledonia, }, + { + name: 'CheatcodesScreenRemoteBanner', + component: CheatcodesScreenRemoteBanner, + }, { name: 'CheatcodesNavigationErrors', component: withAsyncErrorBoundary(CheatcodesNavigationErrors), diff --git a/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigatorConfig.tsx b/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigatorConfig.tsx index d591bb95bb3..e5f8d0b47ec 100644 --- a/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigatorConfig.tsx +++ b/src/features/navigation/CheatcodesStackNavigator/CheatcodesStackNavigatorConfig.tsx @@ -3,35 +3,36 @@ export const cheatcodesStackNavigatorConfig = { initialRouteName: 'cheatcodes', screens: { CheatcodesMenu: 'cheatcodes', - CheatcodesNavigationHome: 'cheatcodes/home', - CheatcodesScreenCategoryThematicHomeHeader: 'cheatcodes/home/category-thematic-home-header', - CheatcodesScreenDefaultThematicHomeHeader: 'cheatcodes/home/default-thematic-home-header', - CheatcodesScreenHighlightThematicHomeHeader: 'cheatcodes/home/highlight-thematic-home-header', - CheatcodesNavigationProfile: 'cheatcodes/profile', + CheatcodesNavigationAccountManagement: 'cheatcodes/other/account-management', CheatcodesNavigationAchievements: 'cheatcodes/achievements', - CheatcodesNavigationShare: 'cheatcodes/share', - CheatcodesNavigationSubscription: 'cheatcodes/subscription', + CheatcodesNavigationBookOffer: 'cheatcodes/book-offer', CheatcodesNavigationCulturalSurvey: 'cheatcodes/cultural-survey', - CheatcodesNavigationTutorial: 'cheatcodes/tutorial', - CheatcodesNavigationOnboarding: 'cheatcodes/tutorial/onboarding', - CheatcodesNavigationProfileTutorial: 'cheatcodes/tutorial/profile-tutorial', - CheatcodesNavigationTrustedDevice: 'cheatcodes/trusted-device', - CheatcodesScreenTrustedDeviceInfos: 'cheatcodes/trusted-device/trusted-device-infos', + CheatcodesNavigationErrors: 'cheatcodes/other/errors', + CheatcodesNavigationForceUpdate: 'cheatcodes/other/force-update', + CheatcodesNavigationHome: 'cheatcodes/home', CheatcodesNavigationIdentityCheck: 'cheatcodes/identity-check', + CheatcodesNavigationInternal: 'cheatcodes/internal', CheatcodesNavigationNewIdentificationFlow: 'cheatcodes/identity-check/new-identification-flow', - CheatcodesNavigationInternal: 'cheatcodes/internal', - CheatcodesNavigationBookOffer: 'cheatcodes/book-offer', + CheatcodesNavigationNotScreensPages: 'cheatcodes/other/not-screens-pages', + CheatcodesNavigationOnboarding: 'cheatcodes/tutorial/onboarding', + CheatcodesNavigationProfile: 'cheatcodes/profile', + CheatcodesNavigationProfileTutorial: 'cheatcodes/tutorial/profile-tutorial', + CheatcodesNavigationShare: 'cheatcodes/share', + CheatcodesNavigationSignUp: 'cheatcodes/other/sign-up', + CheatcodesNavigationSubscription: 'cheatcodes/subscription', + CheatcodesNavigationTrustedDevice: 'cheatcodes/trusted-device', + CheatcodesNavigationTutorial: 'cheatcodes/tutorial', + CheatcodesScreenAccesLibre: 'cheatcodes/other/acces-libre', + CheatcodesScreenCategoryThematicHomeHeader: 'cheatcodes/home/category-thematic-home-header', CheatcodesScreenDebugInformations: 'cheatcodes/other/debug-informations', + CheatcodesScreenDefaultThematicHomeHeader: 'cheatcodes/home/default-thematic-home-header', CheatcodesScreenFeatureFlags: 'cheatcodes/other/feature-flags', - CheatcodesScreenRemoteConfig: 'cheatcodes/other/remote-config', + CheatcodesScreenHighlightThematicHomeHeader: 'cheatcodes/home/highlight-thematic-home-header', CheatcodesScreenNewCaledonia: 'cheatcodes/other/new-caledonia', - CheatcodesNavigationErrors: 'cheatcodes/other/errors', - CheatcodesNavigationNotScreensPages: 'cheatcodes/other/not-screens-pages', - CheatcodesScreenAccesLibre: 'cheatcodes/other/acces-libre', - CheatcodesNavigationSignUp: 'cheatcodes/other/sign-up', - CheatcodesNavigationAccountManagement: 'cheatcodes/other/account-management', - CheatcodesNavigationForceUpdate: 'cheatcodes/other/force-update', + CheatcodesScreenRemoteBanner: 'cheatcodes/remote-banner', + CheatcodesScreenRemoteConfig: 'cheatcodes/other/remote-config', + CheatcodesScreenTrustedDeviceInfos: 'cheatcodes/trusted-device/trusted-device-infos', }, }, } diff --git a/src/features/navigation/CheatcodesStackNavigator/types.ts b/src/features/navigation/CheatcodesStackNavigator/types.ts index 40041a9c965..59fb7af9f83 100644 --- a/src/features/navigation/CheatcodesStackNavigator/types.ts +++ b/src/features/navigation/CheatcodesStackNavigator/types.ts @@ -20,6 +20,7 @@ export type CheatcodesStackParamList = { CheatcodesScreenCategoryThematicHomeHeader: undefined CheatcodesScreenDefaultThematicHomeHeader: undefined CheatcodesScreenHighlightThematicHomeHeader: undefined + CheatcodesScreenRemoteBanner: undefined CheatcodesScreenTrustedDeviceInfos: undefined // Others CheatcodesScreenDebugInformations: undefined diff --git a/src/features/profile/components/Badges/SubscriptionMessageBadge.tsx b/src/features/profile/components/Badges/SubscriptionMessageBadge.tsx index e96c90f281d..8ed5ebc9f54 100644 --- a/src/features/profile/components/Badges/SubscriptionMessageBadge.tsx +++ b/src/features/profile/components/Badges/SubscriptionMessageBadge.tsx @@ -3,11 +3,11 @@ import { openInbox } from 'react-native-email-link' import { SubscriptionMessage } from 'api/gen' import { useIsMailAppAvailable } from 'features/auth/helpers/useIsMailAppAvailable' -import { ForceUpdateBanner } from 'features/forceUpdate/components/ForceUpdateBanner' import { Subtitle } from 'features/profile/components/Subtitle/Subtitle' import { formatDateToLastUpdatedAtMessage } from 'features/profile/helpers/formatDateToLastUpdatedAtMessage' import { matchSubscriptionMessageIconToSvg } from 'features/profile/helpers/matchSubscriptionMessageIconToSvg' import { shouldOpenInbox as checkShouldOpenInbox } from 'features/profile/helpers/shouldOpenInbox' +import { RemoteBanner } from 'features/remoteBanner/components/RemoteBanner' import { InfoBanner } from 'ui/components/banners/InfoBanner' import { BaseButtonProps } from 'ui/components/buttons/AppButton/types' import { ButtonQuaternarySecondary } from 'ui/components/buttons/ButtonQuaternarySecondary' @@ -88,7 +88,7 @@ export const SubscriptionMessageBadge = ({ {disableActivation ? ( - + ) : null} diff --git a/src/features/profile/components/Header/CreditHeader/CreditHeader.native.test.tsx b/src/features/profile/components/Header/CreditHeader/CreditHeader.native.test.tsx index 93d87ec6618..975e341b561 100644 --- a/src/features/profile/components/Header/CreditHeader/CreditHeader.native.test.tsx +++ b/src/features/profile/components/Header/CreditHeader/CreditHeader.native.test.tsx @@ -219,7 +219,7 @@ describe('CreditHeader', () => { const renderCreditHeader = (props?: Partial) => { render( } diff --git a/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.stories.tsx b/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.stories.tsx index 0bcc29582d7..c60fecede36 100644 --- a/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.stories.tsx +++ b/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.stories.tsx @@ -28,19 +28,19 @@ const Template: ComponentStory = (props) => ( export const WithActivationBanner = Template.bind({}) WithActivationBanner.args = { - showForceUpdateBanner: true, + showRemoteBanner: true, title: 'Jean Dubois', } export const WithTitle = Template.bind({}) WithTitle.args = { - showForceUpdateBanner: false, + showRemoteBanner: false, title: 'Jean Dubois', } export const WithStringSubtitle = Template.bind({}) WithStringSubtitle.args = { - showForceUpdateBanner: false, + showRemoteBanner: false, title: 'Jean Dubois', subtitle: 'Tu as entre 15 et 18 ans\u00a0?', } @@ -52,7 +52,7 @@ const Row = styled.View({ export const WithComponentAsSubtitle = Template.bind({}) WithComponentAsSubtitle.args = { - showForceUpdateBanner: false, + showRemoteBanner: false, title: 'Jean Dubois', subtitle: ( @@ -66,14 +66,14 @@ WithComponentAsSubtitle.args = { export const WithInfoBanner = Template.bind({}) WithInfoBanner.args = { - showForceUpdateBanner: false, + showRemoteBanner: false, title: 'Jean Dubois', bannerText: 'Some really important information', } export const WithLargeContent = Template.bind({}) WithLargeContent.args = { - showForceUpdateBanner: false, + showRemoteBanner: false, title: 'Jean Dubois', subtitle: 'Tu as entre 15 et 18 ans\u00a0?', children: ( @@ -87,7 +87,7 @@ WithLargeContent.args = { export const WithSmallContent = Template.bind({}) WithSmallContent.args = { - showForceUpdateBanner: false, + showRemoteBanner: false, title: 'Jean Dubois', subtitle: 'Tu as entre 15 et 18 ans\u00a0?', children: Lorem ipsum dolor, sit amet consectetur, diff --git a/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.tsx b/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.tsx index cae1b599d8b..dfc451544e8 100644 --- a/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.tsx +++ b/src/features/profile/components/Header/HeaderWithGreyContainer/HeaderWithGreyContainer.tsx @@ -1,14 +1,14 @@ import React, { FunctionComponent, ReactNode } from 'react' import styled from 'styled-components/native' -import { ForceUpdateBanner } from 'features/forceUpdate/components/ForceUpdateBanner' +import { RemoteBanner } from 'features/remoteBanner/components/RemoteBanner' import { InfoBanner } from 'ui/components/banners/InfoBanner' import { PageHeader } from 'ui/components/headers/PageHeader' import { Info } from 'ui/svg/icons/Info' import { getSpacing, Spacer, TypoDS } from 'ui/theme' type PropsWithChildren = { - showForceUpdateBanner: boolean + showRemoteBanner: boolean title: string subtitle?: ReactNode | string withGreyContainer?: boolean @@ -17,7 +17,7 @@ type PropsWithChildren = { } export const HeaderWithGreyContainer: FunctionComponent = ({ - showForceUpdateBanner, + showRemoteBanner, title, subtitle, bannerText, @@ -35,9 +35,9 @@ export const HeaderWithGreyContainer: FunctionComponent = ({ ) : ( )} - {showForceUpdateBanner ? ( + {showRemoteBanner ? ( - + ) : null} diff --git a/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.native.test.tsx b/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.native.test.tsx index 6c0d0c55d72..192cc590114 100644 --- a/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.native.test.tsx +++ b/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.native.test.tsx @@ -19,7 +19,7 @@ describe('LoggedOutHeader', () => { beforeEach(() => setFeatureFlags()) it('should display subtitle with credit V2', () => { - render() + render() const subtitle = 'Tu as entre 15 et 18 ans\u00a0?' @@ -27,7 +27,7 @@ describe('LoggedOutHeader', () => { }) it('should navigate to the SignupForm page', async () => { - render() + render() const signupButton = screen.getByText('Créer un compte') await user.press(signupButton) @@ -38,7 +38,7 @@ describe('LoggedOutHeader', () => { }) it('should navigate to the Login page', async () => { - render() + render() const signinButton = screen.getByText('Se connecter') await user.press(signinButton) @@ -49,7 +49,7 @@ describe('LoggedOutHeader', () => { }) it('should log analytics when clicking on "Créer un compte"', async () => { - render() + render() const signupButton = screen.getByText('Créer un compte') await user.press(signupButton) @@ -64,7 +64,7 @@ describe('LoggedOutHeader', () => { }) it('should display subtitle with credit V3', () => { - render() + render() const subtitle = 'Tu as 17 ou 18 ans\u00a0?' @@ -74,7 +74,7 @@ describe('LoggedOutHeader', () => { it('should not display subtitle with passForAll enabled', () => { setFeatureFlags([RemoteStoreFeatureFlags.ENABLE_PASS_FOR_ALL]) - render() + render() const subtitle = 'Tu as 17 ou 18 ans\u00a0?' diff --git a/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.tsx b/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.tsx index 411fce611a3..79eadfd3846 100644 --- a/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.tsx +++ b/src/features/profile/components/Header/LoggedOutHeader/LoggedOutHeader.tsx @@ -13,7 +13,7 @@ import { InternalTouchableLink } from 'ui/components/touchableLink/InternalTouch import { getSpacing, Spacer, TypoDS } from 'ui/theme' type Props = { - showForceUpdateBanner: boolean + showRemoteBanner: boolean } const onBeforeNavigate = () => { @@ -21,7 +21,7 @@ const onBeforeNavigate = () => { analytics.logSignUpClicked({ from: 'profile' }) } -export const LoggedOutHeader: FunctionComponent = ({ showForceUpdateBanner }) => { +export const LoggedOutHeader: FunctionComponent = ({ showRemoteBanner }) => { const isPassForAllEnabled = useFeatureFlag(RemoteStoreFeatureFlags.ENABLE_PASS_FOR_ALL) const { data: settings } = useSettingsContext() const enableCreditV3 = settings?.wipEnableCreditV3 @@ -32,7 +32,7 @@ export const LoggedOutHeader: FunctionComponent = ({ showForceUpdateBanne return ( {bodyText} diff --git a/src/features/profile/components/Header/NonBeneficiaryHeader/NonBeneficiaryHeader.tsx b/src/features/profile/components/Header/NonBeneficiaryHeader/NonBeneficiaryHeader.tsx index 8888ef3a85b..8df82f66982 100644 --- a/src/features/profile/components/Header/NonBeneficiaryHeader/NonBeneficiaryHeader.tsx +++ b/src/features/profile/components/Header/NonBeneficiaryHeader/NonBeneficiaryHeader.tsx @@ -3,7 +3,6 @@ import React, { FunctionComponent, memo, PropsWithChildren } from 'react' import styled from 'styled-components/native' import { Banner, BannerName } from 'api/gen' -import { ForceUpdateBanner } from 'features/forceUpdate/components/ForceUpdateBanner' import { useActivationBanner } from 'features/home/api/useActivationBanner' import { ActivationBanner } from 'features/home/components/banners/ActivationBanner' import { useGetStepperInfo } from 'features/identityCheck/api/useGetStepperInfo' @@ -12,6 +11,7 @@ import { IdentityCheckPendingBadge } from 'features/profile/components/Badges/Id import { SubscriptionMessageBadge } from 'features/profile/components/Badges/SubscriptionMessageBadge' import { YoungerBadge } from 'features/profile/components/Badges/YoungerBadge' import { EligibilityMessage } from 'features/profile/components/Header/NonBeneficiaryHeader/EligibilityMessage' +import { RemoteBanner } from 'features/remoteBanner/components/RemoteBanner' import { formatToSlashedFrenchDate } from 'libs/dates' import { PageHeader } from 'ui/components/headers/PageHeader' import { SystemBanner as GenericSystemBanner } from 'ui/components/ModuleBanner/SystemBanner' @@ -163,7 +163,7 @@ function NonBeneficiaryBanner({ return ( - + ) } diff --git a/src/features/profile/components/Header/ProfileHeader/ProfileHeader.native.test.tsx b/src/features/profile/components/Header/ProfileHeader/ProfileHeader.native.test.tsx index fb48a9be73a..665494dec82 100644 --- a/src/features/profile/components/Header/ProfileHeader/ProfileHeader.native.test.tsx +++ b/src/features/profile/components/Header/ProfileHeader/ProfileHeader.native.test.tsx @@ -88,7 +88,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: true, }, user: undefined, @@ -105,7 +105,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }, user: undefined, @@ -124,7 +124,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }, user, @@ -140,7 +140,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }, user, @@ -155,7 +155,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }, user: exBeneficiaryUser, @@ -178,7 +178,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }, user: notBeneficiaryUser, @@ -194,7 +194,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: false, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }, user: exUnderageBeneficiaryUser, @@ -212,7 +212,7 @@ const renderProfileHeader = ({ enableAchievements: boolean enableSystemBanner: boolean disableActivation: boolean - showForceUpdateBanner: boolean + showRemoteBanner: boolean enablePassForAll: boolean } user?: UserProfileResponse diff --git a/src/features/profile/components/Header/ProfileHeader/ProfileHeader.tsx b/src/features/profile/components/Header/ProfileHeader/ProfileHeader.tsx index d2f6009b162..985da33d708 100644 --- a/src/features/profile/components/Header/ProfileHeader/ProfileHeader.tsx +++ b/src/features/profile/components/Header/ProfileHeader/ProfileHeader.tsx @@ -17,7 +17,7 @@ type ProfileHeaderProps = { enableAchievements: boolean enableSystemBanner: boolean disableActivation: boolean - showForceUpdateBanner: boolean + showRemoteBanner: boolean enablePassForAll: boolean } user?: UserProfileResponse @@ -33,7 +33,7 @@ export function ProfileHeader(props: ProfileHeaderProps) { const ProfileHeader = useMemo(() => { if (!isLoggedIn || !user) { - return + return } if (!user.isBeneficiary || user.isEligibleForBeneficiaryUpgrade) { @@ -49,7 +49,7 @@ export function ProfileHeader(props: ProfileHeaderProps) { return ( { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }} user={user} @@ -76,7 +76,7 @@ describe('ProfileHeader', () => { enableAchievements: false, enableSystemBanner: true, disableActivation: false, - showForceUpdateBanner: false, + showRemoteBanner: false, enablePassForAll: false, }} user={exBeneficiaryUser} diff --git a/src/features/profile/pages/Profile.native.test.tsx b/src/features/profile/pages/Profile.native.test.tsx index 9c1bb3bddda..c33b6f3d3a5 100644 --- a/src/features/profile/pages/Profile.native.test.tsx +++ b/src/features/profile/pages/Profile.native.test.tsx @@ -38,6 +38,7 @@ import { render, screen, userEvent, + waitFor, } from 'tests/utils' import * as useVersion from 'ui/hooks/useVersion' @@ -164,18 +165,24 @@ describe('Profile component', () => { setFeatureFlags([RemoteStoreFeatureFlags.ENABLE_ACHIEVEMENTS]) }) - it('should show banner when FF is enabled and user is a beneficiary', () => { + it('should show banner when FF is enabled and user is a beneficiary', async () => { mockedUseAuthContext.mockReturnValueOnce({ user: beneficiaryUser }) renderProfile() - expect(screen.getByText('Mes succès')).toBeOnTheScreen() + await waitFor(() => { + // this banner is not shown if the force update banner is shown (which needs to wait for firestore, thus the achievement banner must wait for firestore as well). + expect(screen.getByText('Mes succès')).toBeOnTheScreen() + }) }) it('should not show banner if user is not a beneficiary', async () => { renderProfile() await screen.findByText('Mon profil') - expect(screen.queryByText('Mes succès')).not.toBeOnTheScreen() + await waitFor(() => { + // this banner is not shown if the force update banner is shown (which needs to wait for firestore, thus the achievement banner must wait for firestore as well). + expect(screen.queryByText('Mes succès')).not.toBeOnTheScreen() + }) }) it('should not show banner when FF is disabled', async () => { diff --git a/src/features/profile/pages/Profile.tsx b/src/features/profile/pages/Profile.tsx index 71114c0aeda..58bc3c8de7e 100644 --- a/src/features/profile/pages/Profile.tsx +++ b/src/features/profile/pages/Profile.tsx @@ -60,7 +60,7 @@ const OnlineProfile: React.FC = () => { const enableAchievements = useFeatureFlag(RemoteStoreFeatureFlags.ENABLE_ACHIEVEMENTS) const enableSystemBanner = useFeatureFlag(RemoteStoreFeatureFlags.WIP_APP_V2_SYSTEM_BLOCK) const disableActivation = useFeatureFlag(RemoteStoreFeatureFlags.DISABLE_ACTIVATION) - const showForceUpdateBanner = useFeatureFlag(RemoteStoreFeatureFlags.SHOW_FORCE_UPDATE_BANNER) + const showRemoteBanner = useFeatureFlag(RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER) const enablePassForAll = useFeatureFlag(RemoteStoreFeatureFlags.ENABLE_PASS_FOR_ALL) const { dispatch: favoritesDispatch } = useFavoritesState() @@ -171,7 +171,7 @@ const OnlineProfile: React.FC = () => { enableAchievements, enableSystemBanner, disableActivation, - showForceUpdateBanner, + showRemoteBanner, enablePassForAll, }} user={user} diff --git a/src/features/remoteBanner/components/RemoteBanner.native.test.tsx b/src/features/remoteBanner/components/RemoteBanner.native.test.tsx new file mode 100644 index 00000000000..91ca03b34b2 --- /dev/null +++ b/src/features/remoteBanner/components/RemoteBanner.native.test.tsx @@ -0,0 +1,173 @@ +import React from 'react' + +import * as NavigationHelpers from 'features/navigation/helpers/openUrl' +import { RemoteBanner } from 'features/remoteBanner/components/RemoteBanner' +import { + RemoteBannerRedirectionType, + RemoteBannerType, +} from 'features/remoteBanner/components/remoteBannerSchema' +import { analytics } from 'libs/analytics/provider' +import { setFeatureFlags } from 'libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags' +import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' +import { eventMonitoring } from 'libs/monitoring/services' +import { render, screen, userEvent } from 'tests/utils' + +jest.mock('libs/firebase/analytics/analytics') + +const openUrl = jest.spyOn(NavigationHelpers, 'openUrl') + +jest.useFakeTimers() +const user = userEvent.setup() +const appStoreUrl = 'https://apps.apple.com/fr/app/pass-culture/id1557887412' + +describe('RemoteBanner', () => { + it('when the showRemoteBanner FF is off, the banner should not be displayed', () => { + setFeatureFlags() + render() + + const banner = screen.queryByText('title 1') + + expect(banner).not.toBeOnTheScreen() + }) + + it('when redirection type is an expected value, banner should appear', () => { + setFeatureFlags([ + { featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, options: bannerExternalUrl }, + ]) + render() + + const banner = screen.queryByText('title 1') + + expect(banner).toBeOnTheScreen() + }) + + it('when redirection type is an unexpected value, banner should not appear', () => { + setFeatureFlags([ + { featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, options: bannerBadType }, + ]) + render() + + const banner = screen.queryByText('title 1') + + expect(banner).not.toBeOnTheScreen() + }) + + it('when redirection type is an unexpected value, should log to sentry', () => { + setFeatureFlags([ + { featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, options: bannerBadType }, + ]) + render() + + expect(eventMonitoring.captureException).toHaveBeenCalledWith( + new Error( + 'RemoteBanner validation issue: ValidationError: redirectionType must be one of the following values: external, store' + ), + { + extra: { + objectToValidate: { + ...bannerBadType, + }, + }, + } + ) + }) + + it('when redirection is to app store, should navigate to store and a11y label should be correct', async () => { + setFeatureFlags([ + { featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, options: bannerAppStore }, + ]) + render() + + const banner = await screen.findByText('title 1') + await user.press(banner) + + const accessibilityLabel = await screen.findByLabelText(`Nouvelle fenêtre : ${appStoreUrl}`) + + expect(accessibilityLabel).toBeTruthy() + expect(openUrl).toHaveBeenCalledWith(appStoreUrl) + }) + + it('when redirection is external, should navigate to url and a11y label should be correct', async () => { + setFeatureFlags([ + { featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, options: bannerExternalUrl }, + ]) + render() + + const banner = await screen.findByText('title 1') + await user.press(banner) + + const accessibilityLabel = await screen.findByLabelText( + 'Nouvelle fenêtre : https://www.test.fr' + ) + + expect(accessibilityLabel).toBeTruthy() + expect(openUrl).toHaveBeenCalledWith('https://www.test.fr') + }) + + it('when redirection is external, but url is an empty string, button should be disabled and there should not be an a11y label', async () => { + setFeatureFlags([ + { + featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, + options: bannerExternalUrlWithMissingUrl, + }, + ]) + render() + + const banner = await screen.findByText('title 1') + await user.press(banner) + + const accessibilityLabel = screen.queryByLabelText('Nouvelle fenêtre : https://www.test.fr') + + expect(accessibilityLabel).toBeFalsy() + expect(openUrl).not.toHaveBeenCalled() + }) + + it('when user presses banner, should log analytics', async () => { + setFeatureFlags([ + { + featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, + options: bannerAppStore, + }, + ]) + render() + + const banner = await screen.findByText('title 1') + await user.press(banner) + + expect(analytics.logHasClickedRemoteBanner).toHaveBeenCalledWith('Profile', { + ...bannerAppStore, + }) + }) +}) + +const bannerAppStore: RemoteBannerType = { + title: 'title 1', + subtitleMobile: 'subtitleMobile 1', + subtitleWeb: 'subtitleWeb 1', + redirectionUrl: 'https://www.test.fr', + redirectionType: RemoteBannerRedirectionType.STORE, +} + +const bannerExternalUrl: RemoteBannerType = { + title: 'title 1', + subtitleMobile: 'subtitleMobile 1', + subtitleWeb: 'subtitleWeb 1', + redirectionUrl: 'https://www.test.fr', + redirectionType: RemoteBannerRedirectionType.EXTERNAL, +} + +const bannerExternalUrlWithMissingUrl: Partial = { + title: 'title 1', + subtitleMobile: 'subtitleMobile 1', + subtitleWeb: 'subtitleWeb 1', + redirectionUrl: '', + redirectionType: RemoteBannerRedirectionType.EXTERNAL, +} + +const bannerBadType: Partial = { + title: 'title 1', + subtitleMobile: 'subtitleMobile 1', + subtitleWeb: 'subtitleWeb 1', + redirectionUrl: 'https://www.test.fr', + redirectionType: 'other', +} diff --git a/src/features/remoteBanner/components/RemoteBanner.tsx b/src/features/remoteBanner/components/RemoteBanner.tsx new file mode 100644 index 00000000000..0315a011584 --- /dev/null +++ b/src/features/remoteBanner/components/RemoteBanner.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { Platform } from 'react-native' +import styled from 'styled-components/native' + +import { STORE_LINK } from 'features/forceUpdate/constants' +import { onPressStoreLink } from 'features/forceUpdate/helpers/onPressStoreLink' +import { openUrl } from 'features/navigation/helpers/openUrl' +import { + RemoteBannerRedirectionType, + validateRemoteBanner, +} from 'features/remoteBanner/components/remoteBannerSchema' +import { accessibilityAndTestId } from 'libs/accessibilityAndTestId' +import { analytics } from 'libs/analytics/provider' +import { useFeatureFlagOptions } from 'libs/firebase/firestore/featureFlags/useFeatureFlagOptions' +import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' +import { BannerWithBackground } from 'ui/components/ModuleBanner/BannerWithBackground' +import { ArrowAgain } from 'ui/svg/icons/ArrowAgain' +import { TypoDS } from 'ui/theme' + +const isWeb = Platform.OS === 'web' + +export type RemoteBannerOrigin = 'Profile' | 'Home' | 'Cheatcodes' + +export const RemoteBanner = ({ from }: { from: RemoteBannerOrigin }) => { + const { options } = useFeatureFlagOptions(RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER) + const validatedOptions = validateRemoteBanner(options) + if (!validatedOptions) return null + const { title, subtitleMobile, subtitleWeb, redirectionUrl, redirectionType } = validatedOptions + + const isStoreRedirection = redirectionType === RemoteBannerRedirectionType.STORE + const isExternalRedirection = redirectionType === RemoteBannerRedirectionType.EXTERNAL + const isExternalAndDefined = isExternalRedirection && redirectionUrl + + const storeAccessibilityLabel = isStoreRedirection ? `Nouvelle fenêtre\u00a0: ${STORE_LINK}` : '' + const accessibilityLabel = isExternalAndDefined + ? `Nouvelle fenêtre\u00a0: ${String(redirectionUrl)}` + : storeAccessibilityLabel + + const onPress = () => { + analytics.logHasClickedRemoteBanner(from, validatedOptions) + if (isStoreRedirection) onPressStoreLink() + if (isExternalAndDefined) openUrl(redirectionUrl) + } + + return ( + + {title} + {isWeb ? subtitleWeb : subtitleMobile} + + ) +} + +const StyledButtonText = styled(TypoDS.Button)(({ theme }) => ({ + color: theme.colors.white, +})) + +const StyledBodyText = styled(TypoDS.Body)(({ theme }) => ({ + color: theme.colors.white, +})) diff --git a/src/features/remoteBanner/components/RemoteBanner.web.test.tsx b/src/features/remoteBanner/components/RemoteBanner.web.test.tsx new file mode 100644 index 00000000000..a58978b59ca --- /dev/null +++ b/src/features/remoteBanner/components/RemoteBanner.web.test.tsx @@ -0,0 +1,45 @@ +import React from 'react' + +import { RemoteBanner } from 'features/remoteBanner/components/RemoteBanner' +import { + RemoteBannerRedirectionType, + RemoteBannerType, +} from 'features/remoteBanner/components/remoteBannerSchema' +import { setFeatureFlags } from 'libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags' +import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' +import { render, screen } from 'tests/utils/web' + +jest.mock('libs/firebase/analytics/analytics') +jest.mock('libs/firebase/remoteConfig/remoteConfig.services') + +describe('RemoteBanner', () => { + it('should show web specific subtitle', async () => { + setFeatureFlags([ + { featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, options: bannerExternalUrl }, + ]) + render() + + const subtitle = screen.queryByText('subtitleWeb') + + expect(subtitle).toBeInTheDocument() + }) + + it('should not show mobile specific subtitle', async () => { + setFeatureFlags([ + { featureFlag: RemoteStoreFeatureFlags.SHOW_REMOTE_BANNER, options: bannerExternalUrl }, + ]) + render() + + const subtitle = screen.queryByText('subtitleMobile') + + expect(subtitle).not.toBeInTheDocument() + }) +}) + +const bannerExternalUrl: RemoteBannerType = { + title: 'title', + subtitleMobile: 'subtitleMobile', + subtitleWeb: 'subtitleWeb', + redirectionUrl: 'https://www.test.fr', + redirectionType: RemoteBannerRedirectionType.EXTERNAL, +} diff --git a/src/features/remoteBanner/components/remoteBannerSchema.ts b/src/features/remoteBanner/components/remoteBannerSchema.ts new file mode 100644 index 00000000000..1340ea48c54 --- /dev/null +++ b/src/features/remoteBanner/components/remoteBannerSchema.ts @@ -0,0 +1,29 @@ +import { InferType, object, string } from 'yup' + +import { eventMonitoring } from 'libs/monitoring/services' + +export const remoteBannerSchema = object({ + title: string().required(), + subtitleWeb: string().nullable(), + subtitleMobile: string().nullable(), + redirectionUrl: string().url().nullable(), + redirectionType: string().oneOf(['external', 'store']).required(), +}) + +export type RemoteBannerType = InferType + +export enum RemoteBannerRedirectionType { + STORE = 'store', + EXTERNAL = 'external', +} + +export const validateRemoteBanner = (objectToValidate: unknown): RemoteBannerType | null => { + try { + return remoteBannerSchema.validateSync(objectToValidate) + } catch (error) { + eventMonitoring.captureException(new Error(`RemoteBanner validation issue: ${String(error)}`), { + extra: { objectToValidate }, + }) + return null // Should handle case when null in calling component + } +} diff --git a/src/libs/analytics/__mocks__/logEventAnalytics.ts b/src/libs/analytics/__mocks__/logEventAnalytics.ts index 379c23cdda0..091987a402f 100644 --- a/src/libs/analytics/__mocks__/logEventAnalytics.ts +++ b/src/libs/analytics/__mocks__/logEventAnalytics.ts @@ -94,8 +94,9 @@ export const logEventAnalytics: typeof actualLogEventAnalytics = { logHasChosenPrice: jest.fn(), logHasChosenTime: jest.fn(), logHasClickedDuoStep: jest.fn(), - logHasClickedTutorialFAQ: jest.fn(), logHasClickedMissingCode: jest.fn(), + logHasClickedRemoteBanner: jest.fn(), + logHasClickedTutorialFAQ: jest.fn(), logHasCorrectedEmail: jest.fn(), logHasDismissedAppSharingModal: jest.fn(), logHasDismissedModal: jest.fn(), diff --git a/src/libs/analytics/logEventAnalytics.ts b/src/libs/analytics/logEventAnalytics.ts index 234b33a94df..870e166ba79 100644 --- a/src/libs/analytics/logEventAnalytics.ts +++ b/src/libs/analytics/logEventAnalytics.ts @@ -24,6 +24,8 @@ import { } from 'features/navigation/RootNavigator/types' import { SearchStackRouteName } from 'features/navigation/SearchStackNavigator/types' import { PlaylistType } from 'features/offer/enums' +import { RemoteBannerOrigin } from 'features/remoteBanner/components/RemoteBanner' +import { RemoteBannerType } from 'features/remoteBanner/components/remoteBannerSchema' import { SearchState } from 'features/search/types' import { ShareAppModalType } from 'features/share/types' import { SubscriptionAnalyticsParams } from 'features/subscription/types' @@ -356,6 +358,8 @@ export const logEventAnalytics = { logHasClickedDuoStep: () => analytics.logEvent({ firebase: AnalyticsEvent.HAS_CLICKED_DUO_STEP }), logHasClickedMissingCode: () => analytics.logEvent({ firebase: AnalyticsEvent.HAS_CLICKED_MISSING_CODE }), + logHasClickedRemoteBanner: (from: RemoteBannerOrigin, options: RemoteBannerType) => + analytics.logEvent({ firebase: AnalyticsEvent.HAS_CLICKED_REMOTE_BANNER }, { from, options }), logHasClickedTutorialFAQ: () => analytics.logEvent({ firebase: AnalyticsEvent.HAS_CLICKED_TUTORIAL_FAQ }), logHasCorrectedEmail: ({ from }: { from: Referrals }) => diff --git a/src/libs/firebase/analytics/events.ts b/src/libs/firebase/analytics/events.ts index ee6cd806750..f018374f37d 100644 --- a/src/libs/firebase/analytics/events.ts +++ b/src/libs/firebase/analytics/events.ts @@ -90,6 +90,7 @@ export enum AnalyticsEvent { HAS_CHOSEN_TIME = 'HasChosenTime', HAS_CLICKED_DUO_STEP = 'HasClickedDuoStep', HAS_CLICKED_MISSING_CODE = 'HasClickedMissingCode', + HAS_CLICKED_REMOTE_BANNER = 'HasClickedRemoteBanner', HAS_CLICKED_TUTORIAL_FAQ = 'HasClickedTutorialFAQ', HAS_CORRECTED_EMAIL = 'HasCorrectedEmail', HAS_DISMISSED_APP_SHARING_MODAL = 'HasDismissedAppSharingModal', diff --git a/src/libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags.tsx b/src/libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags.tsx index 1f354886e20..0abf725a480 100644 --- a/src/libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags.tsx +++ b/src/libs/firebase/firestore/featureFlags/__tests__/setFeatureFlags.tsx @@ -1,7 +1,22 @@ -import * as useFeatureFlagAPI from 'libs/firebase/firestore/featureFlags/useFeatureFlag' +import * as useFeatureFlagOptionsAPI from 'libs/firebase/firestore/featureFlags/useFeatureFlagOptions' import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' -export const setFeatureFlags = (activeFeatureFlags: RemoteStoreFeatureFlags[] = []) => { - const useFeatureFlagSpy = jest.spyOn(useFeatureFlagAPI, 'useFeatureFlag') - useFeatureFlagSpy.mockImplementation((flag) => activeFeatureFlags.includes(flag)) +type ActiveFeatureFlag = { + featureFlag: RemoteStoreFeatureFlags + options: Record +} + +type ActiveFeatureFlags = (RemoteStoreFeatureFlags | ActiveFeatureFlag)[] + +export const setFeatureFlags = (activeFeatureFlags: ActiveFeatureFlags = []) => { + const useFeatureFlagSpy = jest.spyOn(useFeatureFlagOptionsAPI, 'useFeatureFlagOptions') + useFeatureFlagSpy.mockImplementation((flag): useFeatureFlagOptionsAPI.FeatureFlagOptions => { + const featureFlagRecord = activeFeatureFlags.find( + (item) => typeof item === 'object' && flag === item.featureFlag + ) + return { + isFeatureFlagActive: activeFeatureFlags.includes(flag) || !!featureFlagRecord, + options: typeof featureFlagRecord === 'object' ? featureFlagRecord.options : undefined, + } + }) } diff --git a/src/libs/firebase/firestore/featureFlags/types.ts b/src/libs/firebase/firestore/featureFlags/types.ts index c189fcdc77f..40d4afb451a 100644 --- a/src/libs/firebase/firestore/featureFlags/types.ts +++ b/src/libs/firebase/firestore/featureFlags/types.ts @@ -1,7 +1,15 @@ import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' import { FirebaseFirestoreTypes } from 'libs/firebase/shims/firestore' -export type FeatureFlagConfig = { minimalBuildNumber?: number; maximalBuildNumber?: number } +export type squads = 'decouverte' | 'activation' | 'conversion' + +export type FeatureFlagConfig = { + minimalBuildNumber?: number + maximalBuildNumber?: number + owner?: squads + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options?: Record // Tried with unknown but got: Type 'Record' is not assignable to type 'DocumentFieldType'. +} export type FeatureFlagStore = Record diff --git a/src/libs/firebase/firestore/featureFlags/useFeatureFlag.ts b/src/libs/firebase/firestore/featureFlags/useFeatureFlag.ts index c017dcab0cd..64328a23543 100644 --- a/src/libs/firebase/firestore/featureFlags/useFeatureFlag.ts +++ b/src/libs/firebase/firestore/featureFlags/useFeatureFlag.ts @@ -1,51 +1,6 @@ -import { onlineManager, useQuery } from 'react-query' - -import { getAllFeatureFlags } from 'libs/firebase/firestore/featureFlags/getAllFeatureFlags' -import { FeatureFlagConfig } from 'libs/firebase/firestore/featureFlags/types' +import { useFeatureFlagOptions } from 'libs/firebase/firestore/featureFlags/useFeatureFlagOptions' import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' -import { useLogTypeFromRemoteConfig } from 'libs/hooks/useLogTypeFromRemoteConfig' -import { LogTypeEnum } from 'libs/monitoring/errors' -import { eventMonitoring } from 'libs/monitoring/services' -import { getAppBuildVersion } from 'libs/packageJson' -import { QueryKeys } from 'libs/queryKeys' - -const appBuildVersion = getAppBuildVersion() -// firestore feature flag documentation: -// https://www.notion.so/passcultureapp/Feature-Flag-e7b0da7946f64020b8403e3581b4ed42#fff5fb17737240c9996c432117acacd8 export const useFeatureFlag = (featureFlag: RemoteStoreFeatureFlags): boolean => { - const { data: docSnapshot } = useQuery(QueryKeys.FEATURE_FLAGS, getAllFeatureFlags, { - staleTime: 1000 * 30, // 30 seconds - enabled: onlineManager.isOnline(), - }) - const { logType } = useLogTypeFromRemoteConfig() - - if (!docSnapshot) return false - - const { minimalBuildNumber, maximalBuildNumber } = - docSnapshot.get(featureFlag) ?? {} - - if (minimalBuildNumber === undefined && maximalBuildNumber === undefined) return false - - if ( - !!(minimalBuildNumber && maximalBuildNumber) && - minimalBuildNumber > maximalBuildNumber && - logType === LogTypeEnum.INFO - ) { - eventMonitoring.captureException( - `Minimal build number is greater than maximal build number for feature flag ${featureFlag}`, - { - level: logType, - extra: { - minimalBuildNumber, - maximalBuildNumber, - }, - } - ) - return false - } - return ( - (!minimalBuildNumber || minimalBuildNumber <= appBuildVersion) && - (!maximalBuildNumber || maximalBuildNumber >= appBuildVersion) - ) + return useFeatureFlagOptions(featureFlag).isFeatureFlagActive } diff --git a/src/libs/firebase/firestore/featureFlags/useFeatureFlagOptions.native.test.ts b/src/libs/firebase/firestore/featureFlags/useFeatureFlagOptions.native.test.ts new file mode 100644 index 00000000000..4b2ddcab021 --- /dev/null +++ b/src/libs/firebase/firestore/featureFlags/useFeatureFlagOptions.native.test.ts @@ -0,0 +1,260 @@ +import { useFeatureFlagOptions } from 'libs/firebase/firestore/featureFlags/useFeatureFlagOptions' +import { + FIRESTORE_ROOT_COLLECTION, + RemoteStoreDocuments, + RemoteStoreFeatureFlags, +} from 'libs/firebase/firestore/types' +import { DEFAULT_REMOTE_CONFIG } from 'libs/firebase/remoteConfig/remoteConfig.constants' +import * as useRemoteConfigContext from 'libs/firebase/remoteConfig/RemoteConfigProvider' +import firestore from 'libs/firebase/shims/firestore' +import { eventMonitoring } from 'libs/monitoring/services' +import { getAppBuildVersion } from 'libs/packageJson' +import { reactQueryProviderHOC } from 'tests/reactQueryProviderHOC' +import { act, renderHook } from 'tests/utils' + +const buildVersion = getAppBuildVersion() + +jest.mock('libs/monitoring/services') +jest.mock('@react-native-firebase/firestore') + +const { collection } = firestore() + +const mockGet = jest.fn() + +const featureFlag = RemoteStoreFeatureFlags.WIP_DISABLE_STORE_REVIEW +const useRemoteConfigContextSpy = jest.spyOn(useRemoteConfigContext, 'useRemoteConfigContext') + +describe('useFeatureFlagOptions', () => { + beforeAll(() => + collection(FIRESTORE_ROOT_COLLECTION) + .doc(RemoteStoreDocuments.FEATURE_FLAGS) + // @ts-expect-error is a mock + .get.mockResolvedValue({ + get: mockGet, + }) + ) + + it('should deactivate FF when no build number is given', async () => { + const firestoreData = {} + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBeFalsy() + }) + + describe('minimalBuildNumber', () => { + it.each` + firebaseFeatureFlag | minimalBuildNumber | expected + ${false} | ${buildVersion + 1} | ${'disabled when build number is below firestore minimalBuildNumber'} + ${true} | ${buildVersion} | ${'enabled when build number is equal to firestore minimalBuildNumber'} + ${true} | ${buildVersion - 1} | ${'enabled when build number is greater than firestore minimalBuildNumber'} + `( + `should be $expected`, + async ({ + firebaseFeatureFlag, + minimalBuildNumber, + }: { + firebaseFeatureFlag: boolean + minimalBuildNumber: number + }) => { + const firestoreData = { minimalBuildNumber } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBe(firebaseFeatureFlag) + } + ) + }) + + describe('maximalBuildNumber', () => { + it('should activate FF when version is below maximalBuildNumber', async () => { + const firestoreData = { maximalBuildNumber: buildVersion + 1 } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBeTruthy() + }) + + it('should activate FF when version is equal to maximalBuildNumber', async () => { + const firestoreData = { maximalBuildNumber: buildVersion } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current).toBeTruthy() + }) + + it('should deactivate FF when version is greater than maximalBuildNumber', async () => { + const firestoreData = { maximalBuildNumber: buildVersion - 1 } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBeFalsy() + }) + }) + + describe('maximal and minimal build numbers', () => { + it('should activate FF when version is between minimalBuildNumber and maximalBuildNumber', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion - 1, + maximalBuildNumber: buildVersion + 1, + } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBeTruthy() + }) + + it('should activate FF when minimalBuildNumber and maximalBuildNumber are equal to current version', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion, + maximalBuildNumber: buildVersion, + } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current).toBeTruthy() + }) + + it('should deactivate FF when minimalBuildNumber and maximalBuildNumber are equal and below current version', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion - 1, + maximalBuildNumber: buildVersion - 1, + } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBeFalsy() + }) + + it('should deactivate FF when minimalBuildNumber and maximalBuildNumber are equal and greater than current version', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion + 1, + maximalBuildNumber: buildVersion + 1, + } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBeFalsy() + }) + + it('should deactivate FF when minimalBuildNumber is greater than maximalBuildNumber', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion + 1, + maximalBuildNumber: buildVersion, + } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.isFeatureFlagActive).toBeFalsy() + }) + + describe('When shouldLogInfo remote config is false', () => { + beforeAll(() => { + useRemoteConfigContextSpy.mockReturnValue({ + ...DEFAULT_REMOTE_CONFIG, + shouldLogInfo: false, + }) + }) + + it('should not log to sentry when minimalBuildNumber is greater than maximalBuildNumber', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion + 1, + maximalBuildNumber: buildVersion, + } + mockGet.mockReturnValueOnce(firestoreData) + + renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(eventMonitoring.captureException).toHaveBeenCalledTimes(0) + }) + }) + + describe('When shouldLogInfo remote config is true', () => { + beforeAll(() => { + useRemoteConfigContextSpy.mockReturnValue({ + ...DEFAULT_REMOTE_CONFIG, + shouldLogInfo: true, + }) + }) + + afterAll(() => { + useRemoteConfigContextSpy.mockReturnValue(DEFAULT_REMOTE_CONFIG) + }) + + it('should log to sentry when minimalBuildNumber is greater than maximalBuildNumber', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion + 1, + maximalBuildNumber: buildVersion, + } + mockGet.mockReturnValueOnce(firestoreData) + + renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(eventMonitoring.captureException).toHaveBeenCalledWith( + `Minimal build number is greater than maximal build number for feature flag ${featureFlag}`, + { level: 'info', extra: firestoreData } + ) + }) + }) + }) + + describe('other data', () => { + it('should include owner squad and options when included', async () => { + const firestoreData = { + minimalBuildNumber: buildVersion + 1, + maximalBuildNumber: buildVersion, + owner: 'activation', + options: { + option1: 'value1', + }, + } + mockGet.mockReturnValueOnce(firestoreData) + + const { result } = renderUseFeatureFlag(featureFlag) + + await act(async () => {}) + + expect(result.current.options).toEqual({ option1: 'value1' }) + expect(result.current.owner).toEqual('activation') + }) + }) +}) + +const renderUseFeatureFlag = (featureFlag: RemoteStoreFeatureFlags) => + renderHook(() => useFeatureFlagOptions(featureFlag), { + wrapper: ({ children }) => reactQueryProviderHOC(children), + }) diff --git a/src/libs/firebase/firestore/featureFlags/useFeatureFlagOptions.ts b/src/libs/firebase/firestore/featureFlags/useFeatureFlagOptions.ts new file mode 100644 index 00000000000..288fee70481 --- /dev/null +++ b/src/libs/firebase/firestore/featureFlags/useFeatureFlagOptions.ts @@ -0,0 +1,62 @@ +import { onlineManager, useQuery } from 'react-query' + +import { getAllFeatureFlags } from 'libs/firebase/firestore/featureFlags/getAllFeatureFlags' +import { FeatureFlagConfig, squads } from 'libs/firebase/firestore/featureFlags/types' +import { RemoteStoreFeatureFlags } from 'libs/firebase/firestore/types' +import { useLogTypeFromRemoteConfig } from 'libs/hooks/useLogTypeFromRemoteConfig' +import { LogTypeEnum } from 'libs/monitoring/errors' +import { eventMonitoring } from 'libs/monitoring/services' +import { getAppBuildVersion } from 'libs/packageJson' +import { QueryKeys } from 'libs/queryKeys' + +const appBuildVersion = getAppBuildVersion() + +export type FeatureFlagOptions = { + isFeatureFlagActive: boolean + owner?: squads + options?: Record +} + +// firestore feature flag documentation: +// https://www.notion.so/passcultureapp/Feature-Flag-e7b0da7946f64020b8403e3581b4ed42#fff5fb17737240c9996c432117acacd8 +export const useFeatureFlagOptions = (featureFlag: RemoteStoreFeatureFlags): FeatureFlagOptions => { + const { data: docSnapshot, isLoading } = useQuery(QueryKeys.FEATURE_FLAGS, getAllFeatureFlags, { + staleTime: 1000 * 30, // 30 seconds + enabled: onlineManager.isOnline(), + }) + const { logType } = useLogTypeFromRemoteConfig() + + if (isLoading || !docSnapshot) return { isFeatureFlagActive: false } + + const { minimalBuildNumber, maximalBuildNumber, options, owner } = + docSnapshot.get(featureFlag) ?? {} + + if (minimalBuildNumber === undefined && maximalBuildNumber === undefined) + return { isFeatureFlagActive: false, owner, options } + + if ( + !!(minimalBuildNumber && maximalBuildNumber) && + minimalBuildNumber > maximalBuildNumber && + logType === LogTypeEnum.INFO + ) { + eventMonitoring.captureException( + `Minimal build number is greater than maximal build number for feature flag ${featureFlag}`, + { + level: logType, + extra: { + minimalBuildNumber, + maximalBuildNumber, + }, + } + ) + return { isFeatureFlagActive: false, owner, options } + } + + return { + isFeatureFlagActive: + (!minimalBuildNumber || minimalBuildNumber <= appBuildVersion) && + (!maximalBuildNumber || maximalBuildNumber >= appBuildVersion), + owner, + options, + } +} diff --git a/src/libs/firebase/firestore/types.ts b/src/libs/firebase/firestore/types.ts index 03a45aeeea3..7a6de406956 100644 --- a/src/libs/firebase/firestore/types.ts +++ b/src/libs/firebase/firestore/types.ts @@ -54,7 +54,7 @@ export enum RemoteStoreFeatureFlags { ENABLE_PACIFIC_FRANC_CURRENCY = 'enablePacificFrancCurrency', ENABLE_PASS_FOR_ALL = 'enablePassForAll', ENABLE_REPLICA_ALGOLIA_INDEX = 'enableReplicaAlgoliaIndex', - SHOW_FORCE_UPDATE_BANNER = 'showForceUpdateBanner', + SHOW_REMOTE_BANNER = 'showRemoteBanner', TARGET_XP_CINE_FROM_OFFER = 'targetXpCineFromOffer', WIP_APP_V2_BUSINESS_BLOCK = 'wipAppV2BusinessBlock', WIP_APP_V2_CATEGORY_BLOCK = 'wipAppV2CategoryBlock', diff --git a/src/ui/components/ModuleBanner/BannerWithBackground.tsx b/src/ui/components/ModuleBanner/BannerWithBackground.tsx index aba3424e2aa..c28c42b9099 100644 --- a/src/ui/components/ModuleBanner/BannerWithBackground.tsx +++ b/src/ui/components/ModuleBanner/BannerWithBackground.tsx @@ -25,6 +25,7 @@ type BannerWithBackgroundProps = TouchableProps & { rightIcon?: FunctionComponent backgroundSource?: ImageSourcePropType testID?: string + disabled?: boolean children: React.ReactNode } @@ -34,6 +35,7 @@ export const BannerWithBackground: FunctionComponent children, backgroundSource, testID, + disabled = false, ...touchableProps }) => { const StyledLeftIcon = @@ -54,7 +56,7 @@ export const BannerWithBackground: FunctionComponent const TouchableComponent = 'navigateTo' in touchableProps ? StyledTouchableLink : TouchableOpacity return ( - +