From 592673a9e6d5ae11049b662b79eeb27c9396f005 Mon Sep 17 00:00:00 2001 From: Alessandro Dell'Oste Date: Mon, 10 Jun 2024 13:35:21 +0200 Subject: [PATCH] chore: [IOPAE-1187] Add services Mixpanel analytics events (#5804) ## Short description This PR adds Mixpanel events for services. ## List of changes proposed in this pull request - added analitycs events - removed old analitycs events - added middleware ## How to test Check that the events are registered on Mixpanel --------- Co-authored-by: Giuseppe Di Pinto <118166285+giuseppedipinto@users.noreply.github.com> Co-authored-by: Giuseppe Di Pinto --- .../bonus/cgn/components/CgnServiceCTA.tsx | 15 +- ts/features/pn/components/ServiceCTA.tsx | 15 +- .../services/common/analytics/index.ts | 276 ++++++++++++++++++ .../components/ServiceDetailsMetadata.tsx | 35 ++- .../components/ServiceDetailsPreferences.tsx | 25 ++ .../components/ServiceSpecialAction.tsx | 10 - .../details/screens/ServiceDetailsScreen.tsx | 38 ++- .../components/FeaturedInstitutionList.tsx | 6 + .../home/components/FeaturedServiceList.tsx | 14 +- .../home/screens/ServicesHomeScreen.tsx | 40 ++- .../screens/InstitutionServicesScreen.tsx | 26 +- .../services/search/screens/SearchScreen.tsx | 20 +- .../components/HeaderFirstLevelHandler.tsx | 7 +- ts/store/middlewares/analytics.ts | 4 +- ts/store/middlewares/serviceAnalytics.ts | 51 ---- 15 files changed, 469 insertions(+), 113 deletions(-) create mode 100644 ts/features/services/common/analytics/index.ts delete mode 100644 ts/store/middlewares/serviceAnalytics.ts diff --git a/ts/features/bonus/cgn/components/CgnServiceCTA.tsx b/ts/features/bonus/cgn/components/CgnServiceCTA.tsx index 1413204cdb3..cb42ca4f7a9 100644 --- a/ts/features/bonus/cgn/components/CgnServiceCTA.tsx +++ b/ts/features/bonus/cgn/components/CgnServiceCTA.tsx @@ -16,6 +16,7 @@ import { fold, isLoading } from "../../../../common/model/RemoteValue"; import { cgnUnsubscribeSelector } from "../store/reducers/unsubscribe"; import { loadServicePreference } from "../../../services/details/store/actions/preference"; import { loadAvailableBonuses } from "../../common/store/actions/availableBonusesTypes"; +import * as analytics from "../../../services/common/analytics"; type CgnServiceCtaProps = { serviceId: ServiceId; @@ -69,11 +70,17 @@ export const CgnServiceCta = ({ serviceId }: CgnServiceCtaProps) => { }, { text: I18n.t("global.buttons.deactivate"), - onPress: () => dispatch(cgnUnsubscribe.request()) + onPress: () => { + analytics.trackSpecialServiceStatusChanged({ + is_active: false, + service_id: serviceId + }); + dispatch(cgnUnsubscribe.request()); + } } ] ), - [dispatch] + [dispatch, serviceId] ); if (!servicePreferenceResponseSuccess) { @@ -102,6 +109,10 @@ export const CgnServiceCta = ({ serviceId }: CgnServiceCtaProps) => { loading={isLoadingStatus} testID="service-activate-bonus-button" onPress={() => { + analytics.trackSpecialServiceStatusChanged({ + is_active: true, + service_id: serviceId + }); dispatch(loadAvailableBonuses.request()); dispatch(cgnActivationStart()); }} diff --git a/ts/features/pn/components/ServiceCTA.tsx b/ts/features/pn/components/ServiceCTA.tsx index ba02f32ee9a..7d7e43464fb 100644 --- a/ts/features/pn/components/ServiceCTA.tsx +++ b/ts/features/pn/components/ServiceCTA.tsx @@ -16,10 +16,9 @@ import { useOnFirstRender } from "../../../utils/hooks/useOnFirstRender"; import { loadServicePreference } from "../../services/details/store/actions/preference"; import { trackPNServiceActivated, - trackPNServiceDeactivated, - trackPNServiceStartActivation, - trackPNServiceStartDeactivation + trackPNServiceDeactivated } from "../analytics"; +import * as analytics from "../../services/common/analytics"; type PnServiceCtaProps = { serviceId: ServiceId; @@ -99,7 +98,10 @@ export const PnServiceCta = ({ serviceId, activate }: PnServiceCtaProps) => { label={I18n.t("features.pn.service.activate")} loading={isLoading} onPress={() => { - trackPNServiceStartActivation(); + analytics.trackSpecialServiceStatusChanged({ + is_active: true, + service_id: serviceId + }); dispatch( pnActivationUpsert.request({ value: true, @@ -120,7 +122,10 @@ export const PnServiceCta = ({ serviceId, activate }: PnServiceCtaProps) => { label={I18n.t("features.pn.service.deactivate")} loading={isLoading} onPress={() => { - trackPNServiceStartDeactivation(); + analytics.trackSpecialServiceStatusChanged({ + is_active: false, + service_id: serviceId + }); dispatch( pnActivationUpsert.request({ value: false, diff --git a/ts/features/services/common/analytics/index.ts b/ts/features/services/common/analytics/index.ts new file mode 100644 index 00000000000..c3b07303c46 --- /dev/null +++ b/ts/features/services/common/analytics/index.ts @@ -0,0 +1,276 @@ +import { getType } from "typesafe-actions"; +import { mixpanel, mixpanelTrack } from "../../../../mixpanel"; +import { Action } from "../../../../store/actions/types"; +import { buildEventProperties } from "../../../../utils/analytics"; +import { searchPaginatedInstitutionsGet } from "../../search/store/actions"; +import { getNetworkErrorMessage } from "../../../../utils/errors"; +import { paginatedServicesGet } from "../../institution/store/actions"; +import { + featuredInstitutionsGet, + featuredServicesGet, + paginatedInstitutionsGet +} from "../../home/store/actions"; +import { loadServicePreference } from "../../details/store/actions/preference"; +import { CTAS } from "../../../messages/types/MessageCTA"; + +type ServiceBaseType = { + service_name: string; +} & InstitutionBaseType; + +type InstitutionBaseType = { + organization_name: string; +}; + +type ServiceDetailsType = { + bottom_cta_available: boolean; + organization_fiscal_code: string; + service_category: "special" | "standard"; + service_id: string; +} & ServiceBaseType; + +type ServiceDetailsConsentType = { + is_special_service: boolean; + main_consent_status: boolean; + push_consent_status: boolean; + read_confirmation_consent_status: boolean; + service_id: string; +}; + +const ConsentTypeLabels = { + inbox: "main", + email: "email", + push: "push", + can_access_message_read_status: "read_confirmation" +} as const; + +type ServiceConsentChangedType = { + consent_type: keyof typeof ConsentTypeLabels; + consent_status: boolean; + service_id: string; +}; + +type ServiceDetailsUserExitType = { + link: string; + service_id: string; +}; + +type SpecialServiceStatusChangedType = { + is_active: boolean; + service_id: string; +}; + +type InstitutionDetailsType = { + organization_fiscal_code: string; + services_count: number; +} & InstitutionBaseType; + +type ServiceSelectedType = { + source: "featured_services" | "organization_detail"; +} & ServiceBaseType; + +type InstitutionSelectedType = { + source: + | "featured_organizations" + | "main_list" + | "search_list" + | "recent_list"; +} & InstitutionBaseType; + +type SearchStartType = { + source: "bottom_link" | "header_icon" | "search_bar"; +}; + +type ServiceDetailsCtaTappedType = { + cta: keyof CTAS; + service_id: string; +}; + +export const trackServicesHome = () => + void mixpanelTrack("SERVICES", buildEventProperties("UX", "screen_view")); + +export const trackServicesHomeError = ( + reason: string, + source: "featured_services" | "featured_organizations" | "main_list" +) => + void mixpanelTrack( + "SERVICES_ERROR", + buildEventProperties("KO", undefined, { reason, source }) + ); + +export const trackInstitutionsScroll = () => + void mixpanelTrack("SERVICES_SCROLL", buildEventProperties("UX", "action")); + +export const trackSearchStart = (props: SearchStartType) => + void mixpanelTrack( + "SERVICES_SEARCH_START", + buildEventProperties("UX", "action", props) + ); + +export const trackServiceSelected = ({ + organization_name, + service_name, + source +}: ServiceSelectedType) => + void mixpanelTrack( + "SERVICES_SELECTED", + buildEventProperties("UX", "action", { + service_name, + organization_name, + source + }) + ); + +export const trackInstitutionSelected = ({ + organization_name, + source +}: InstitutionSelectedType) => + void mixpanelTrack( + "SERVICES_ORGANIZATION_SELECTED", + buildEventProperties("UX", "action", { + organization_name, + source + }) + ); + +export const trackInstitutionDetails = ({ + organization_fiscal_code, + organization_name, + services_count = 0 +}: InstitutionDetailsType) => + void mixpanelTrack( + "SERVICES_ORGANIZATION_DETAIL", + buildEventProperties("UX", "screen_view", { + organization_fiscal_code, + organization_name, + services_count + }) + ); + +export const trackInstitutionDetailsError = (reason: string) => + void mixpanelTrack( + "SERVICES_ORGANIZATION_DETAIL_ERROR", + buildEventProperties("KO", undefined, { reason }) + ); + +export const trackSearchPage = () => + void mixpanelTrack( + "SERVICES_SEARCH_PAGE", + buildEventProperties("UX", "screen_view") + ); + +export const trackSearchInput = () => + void mixpanelTrack( + "SERVICES_SEARCH_INPUT", + buildEventProperties("UX", "action") + ); + +export const trackSearchResult = (results_count: number = 0) => + void mixpanelTrack( + "SERVICES_SEARCH_RESULT_PAGE", + buildEventProperties("UX", "screen_view", { results_count }) + ); + +export const trackSearchResultScroll = () => + void mixpanelTrack( + "SERVICES_SEARCH_RESULT_SCROLL", + buildEventProperties("UX", "action") + ); + +export const trackSearchError = (reason: string) => + void mixpanelTrack( + "SERVICES_SEARCH_ERROR", + buildEventProperties("KO", undefined, { reason }) + ); + +export const trackServiceDetails = (props: ServiceDetailsType) => + void mixpanelTrack( + "SERVICES_DETAIL", + buildEventProperties("UX", "screen_view", props) + ); + +export const trackServiceDetailsConsent = (props: ServiceDetailsConsentType) => + void mixpanelTrack( + "SERVICES_DETAIL_CONSENT", + buildEventProperties("UX", "screen_view", props) + ); + +export const trackServiceConsentChanged = ({ + consent_type, + ...rest +}: ServiceConsentChangedType) => + void mixpanelTrack( + "SERVICES_CONSENT_CHANGED", + buildEventProperties("UX", "action", { + ...rest, + consent_type: ConsentTypeLabels[consent_type] + }) + ); + +export const trackServiceDetailsUserExit = ( + props: ServiceDetailsUserExitType +) => + void mixpanelTrack( + "SERVICES_DETAIL_USER_EXIT", + buildEventProperties("UX", "exit", props) + ); + +export const trackSpecialServiceStatusChanged = ( + props: SpecialServiceStatusChangedType +) => + void mixpanelTrack( + "SERVICES_SPECIAL_SERVICE_STATUS_CHANGED", + buildEventProperties("UX", "action", props) + ); + +export const trackServiceDetailsError = (reason: string) => + void mixpanelTrack( + "SERVICES_DETAIL_ERROR", + buildEventProperties("KO", undefined, { reason }) + ); + +export const trackServiceDetailsCtaTapped = ( + props: ServiceDetailsCtaTappedType +) => + void mixpanelTrack( + "SERVICES_DETAIL_CTA_TAPPED", + buildEventProperties("UX", "action", props) + ); + +/** + * Isolated tracker for services actions + */ +export const trackServicesAction = + (_: NonNullable) => + (action: Action): void => { + switch (action.type) { + // Services home + case getType(paginatedInstitutionsGet.failure): + return trackServicesHomeError( + getNetworkErrorMessage(action.payload), + "main_list" + ); + case getType(featuredServicesGet.failure): + return trackServicesHomeError( + getNetworkErrorMessage(action.payload), + "featured_services" + ); + case getType(featuredInstitutionsGet.failure): + return trackServicesHomeError( + getNetworkErrorMessage(action.payload), + "featured_organizations" + ); + // Search results + case getType(searchPaginatedInstitutionsGet.success): + return trackSearchResult(action.payload.count); + case getType(searchPaginatedInstitutionsGet.failure): + return trackSearchError(getNetworkErrorMessage(action.payload)); + // Institution details + case getType(paginatedServicesGet.failure): + return trackInstitutionDetailsError( + getNetworkErrorMessage(action.payload) + ); + // Service details + case getType(loadServicePreference.failure): + return trackServiceDetailsError(getNetworkErrorMessage(action.payload)); + } + }; diff --git a/ts/features/services/details/components/ServiceDetailsMetadata.tsx b/ts/features/services/details/components/ServiceDetailsMetadata.tsx index c1786097ed3..5dfa0749ad7 100644 --- a/ts/features/services/details/components/ServiceDetailsMetadata.tsx +++ b/ts/features/services/details/components/ServiceDetailsMetadata.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react"; -import { FlatList, Linking, ListRenderItemInfo, Platform } from "react-native"; +import { FlatList, ListRenderItemInfo, Platform } from "react-native"; import { Divider, IOStyles, @@ -9,11 +9,12 @@ import { ListItemInfoCopy } from "@pagopa/io-app-design-system"; import { ServiceId } from "../../../../../definitions/backend/ServiceId"; +import { ServiceMetadata } from "../../../../../definitions/backend/ServiceMetadata"; import I18n from "../../../../i18n"; import { useIOSelector } from "../../../../store/hooks"; import { serviceMetadataByIdSelector } from "../store/reducers/servicesById"; -import { clipboardSetStringWithFeedback } from "../../../../utils/clipboard"; -import { openWebUrl } from "../../../../utils/url"; +import { handleItemOnPress } from "../../../../utils/url"; +import * as analytics from "../../common/analytics"; type MetadataActionListItem = { kind: "ListItemAction"; @@ -59,13 +60,25 @@ export const ServiceDetailsMetadata = ({ web_url } = serviceMetadataById || {}; + const handleOpenUrl = useCallback( + (link: keyof ServiceMetadata, value: string) => { + analytics.trackServiceDetailsUserExit({ + link, + service_id: serviceId + }); + + handleItemOnPress(value)(); + }, + [serviceId] + ); + const metadataListItems: ReadonlyArray = [ { kind: "ListItemAction", condition: !!web_url, icon: "website", label: I18n.t("services.details.metadata.website"), - onPress: () => openWebUrl(`${web_url}`), + onPress: () => handleOpenUrl("web_url", `${web_url}`), testID: "service-details-metadata-web-url" }, { @@ -73,7 +86,7 @@ export const ServiceDetailsMetadata = ({ condition: Platform.OS === "android" && !!app_android, icon: "device", label: I18n.t("services.details.metadata.downloadApp"), - onPress: () => openWebUrl(`${app_android}`), + onPress: () => handleOpenUrl("app_android", `${app_android}`), testID: "service-details-metadata-app-android" }, { @@ -81,7 +94,7 @@ export const ServiceDetailsMetadata = ({ condition: Platform.OS === "ios" && !!app_ios, icon: "device", label: I18n.t("services.details.metadata.downloadApp"), - onPress: () => openWebUrl(`${app_ios}`), + onPress: () => handleOpenUrl("app_ios", `${app_ios}`), testID: "service-details-metadata-app-ios" }, { @@ -89,7 +102,7 @@ export const ServiceDetailsMetadata = ({ condition: !!support_url, icon: "chat", label: I18n.t("services.details.metadata.support"), - onPress: () => openWebUrl(`${support_url}`), + onPress: () => handleOpenUrl("support_url", `${support_url}`), testID: "service-details-metadata-support-url" }, { @@ -97,7 +110,7 @@ export const ServiceDetailsMetadata = ({ icon: "phone", condition: !!phone, label: I18n.t("services.details.metadata.phone"), - onPress: () => Linking.openURL(`tel:${phone}`), + onPress: () => handleOpenUrl("phone", `tel:${phone}`), testID: "service-details-metadata-phone" }, { @@ -105,7 +118,7 @@ export const ServiceDetailsMetadata = ({ condition: !!email, icon: "email", label: I18n.t("services.details.metadata.email"), - onPress: () => Linking.openURL(`mailto:${email}`), + onPress: () => handleOpenUrl("email", `mailto:${email}`), testID: "service-details-metadata-email" }, { @@ -113,14 +126,14 @@ export const ServiceDetailsMetadata = ({ condition: !!pec, icon: "pec", label: I18n.t("services.details.metadata.pec"), - onPress: () => Linking.openURL(`mailto:${pec}`), + onPress: () => handleOpenUrl("pec", `mailto:${pec}`), testID: "service-details-metadata-pec" }, { kind: "ListItemInfoCopy", label: I18n.t("services.details.metadata.fiscalCode"), icon: "entityCode", - onPress: () => clipboardSetStringWithFeedback(organizationFiscalCode), + onPress: handleItemOnPress(serviceId, "COPY"), value: organizationFiscalCode, testID: "service-details-metadata-org-fiscal-code" }, diff --git a/ts/features/services/details/components/ServiceDetailsPreferences.tsx b/ts/features/services/details/components/ServiceDetailsPreferences.tsx index 81e45ee071d..df3f5b7b00a 100644 --- a/ts/features/services/details/components/ServiceDetailsPreferences.tsx +++ b/ts/features/services/details/components/ServiceDetailsPreferences.tsx @@ -15,7 +15,9 @@ import { ServiceId } from "../../../../../definitions/backend/ServiceId"; import I18n from "../../../../i18n"; import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { isPremiumMessagesOptInOutEnabledSelector } from "../../../../store/reducers/backendStatus"; +import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; import { EnabledChannels } from "../../../../utils/profile"; +import * as analytics from "../../common/analytics"; import { useFirstRender } from "../../common/hooks/useFirstRender"; import { upsertServicePreference } from "../store/actions/preference"; import { @@ -75,6 +77,23 @@ export const ServiceDetailsPreferences = ({ const isInboxPreferenceEnabled = servicePreferenceResponseSuccess?.value.inbox ?? false; + useOnFirstRender( + () => { + analytics.trackServiceDetailsConsent({ + is_special_service: serviceMetadataInfo?.isSpecialService ?? false, + main_consent_status: + servicePreferenceResponseSuccess?.value.inbox ?? false, + push_consent_status: + servicePreferenceResponseSuccess?.value.push ?? false, + read_confirmation_consent_status: + servicePreferenceResponseSuccess?.value + .can_access_message_read_status ?? false, + service_id: serviceId + }); + }, + () => !!serviceMetadataInfo && !!servicePreferenceResponseSuccess + ); + useEffect(() => { if (!isFirstRender && isErrorServicePreference) { IOToast.error(I18n.t("global.genericError")); @@ -84,6 +103,12 @@ export const ServiceDetailsPreferences = ({ const handleSwitchValueChange = useCallback( (channel: keyof EnabledChannels, value: boolean) => { if (servicePreferenceResponseSuccess) { + analytics.trackServiceConsentChanged({ + consent_status: value, + consent_type: channel, + service_id: serviceId + }); + dispatch( upsertServicePreference.request({ id: serviceId, diff --git a/ts/features/services/details/components/ServiceSpecialAction.tsx b/ts/features/services/details/components/ServiceSpecialAction.tsx index 7b71432f1af..108584e10c9 100644 --- a/ts/features/services/details/components/ServiceSpecialAction.tsx +++ b/ts/features/services/details/components/ServiceSpecialAction.tsx @@ -4,12 +4,10 @@ import * as O from "fp-ts/lib/Option"; import * as B from "fp-ts/lib/boolean"; import { constNull, pipe } from "fp-ts/lib/function"; import { ServiceId } from "../../../../../definitions/backend/ServiceId"; -import { cdcEnabled } from "../../../../config"; import I18n from "../../../../i18n"; import { useIOSelector } from "../../../../store/hooks"; import { isCGNEnabledSelector, - isCdcEnabledSelector, isPnEnabledSelector, isPnSupportedSelector } from "../../../../store/reducers/backendStatus"; @@ -65,16 +63,11 @@ export const ServiceSpecialAction = ({ customSpecialFlowOpt }: ServiceSpecialActionProps) => { const isCGNEnabled = useIOSelector(isCGNEnabledSelector); - const cdcEnabledSelector = useIOSelector(isCdcEnabledSelector); - - const isCdcEnabled = cdcEnabledSelector && cdcEnabled; - const isPnEnabled = useIOSelector(isPnEnabledSelector); const isPnSupported = useIOSelector(isPnSupportedSelector); const mapSpecialServiceConfig = new Map([ ["cgn", { isEnabled: isCGNEnabled, isSupported: true }], - ["cdc", { isEnabled: isCdcEnabled, isSupported: true }], ["pn", { isEnabled: isPnEnabled, isSupported: isPnSupported }] ]); @@ -95,9 +88,6 @@ export const ServiceSpecialAction = ({ isSupported, ); - case "cdc": - // CdC is disabled: the flow needs to be reviewed - return null; case "pn": return renderCta( isEnabled, diff --git a/ts/features/services/details/screens/ServiceDetailsScreen.tsx b/ts/features/services/details/screens/ServiceDetailsScreen.tsx index f89c6dd99c1..a7363bd8b26 100644 --- a/ts/features/services/details/screens/ServiceDetailsScreen.tsx +++ b/ts/features/services/details/screens/ServiceDetailsScreen.tsx @@ -38,6 +38,8 @@ import { } from "../store/reducers/servicesById"; import { ServiceMetadataInfo } from "../types/ServiceMetadataInfo"; import { ServicesHeaderSection } from "../../common/components/ServicesHeaderSection"; +import * as analytics from "../../common/analytics"; +import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; export type ServiceDetailsScreenRouteParams = { serviceId: ServiceId; @@ -92,6 +94,22 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { [serviceMetadata] ); + useOnFirstRender( + () => { + analytics.trackServiceDetails({ + bottom_cta_available: !!serviceMetadata?.cta, + organization_fiscal_code: service?.organization_fiscal_code ?? "", + organization_name: service?.organization_name ?? "", + service_category: serviceMetadataInfo?.isSpecialService + ? "special" + : "standard", + service_id: serviceId, + service_name: service?.service_name ?? "" + }); + }, + () => !!service && !!serviceMetadataInfo + ); + useEffect(() => { dispatch(loadServiceDetail.request(serviceId)); }, [dispatch, serviceId]); @@ -124,7 +142,13 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { return null; } - const handlePressCta = (cta: CTA) => handleCtaAction(cta, linkTo); + const handlePressCta = (cta: CTA, ctaType: keyof CTAS) => { + analytics.trackServiceDetailsCtaTapped({ + cta: ctaType, + service_id: serviceId + }); + handleCtaAction(cta, linkTo); + }; const getActionsProps = ( ctas?: CTAS, @@ -146,12 +170,12 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { secondaryActionProps: { label: cta_1.text, accessibilityLabel: cta_1.text, - onPress: () => handlePressCta(cta_1) + onPress: () => handlePressCta(cta_1, "cta_1") }, tertiaryActionProps: { label: cta_2.text, accessibilityLabel: cta_2.text, - onPress: () => handlePressCta(cta_2) + onPress: () => handlePressCta(cta_2, "cta_2") } }; } @@ -169,7 +193,7 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { secondaryActionProps: { label: cta_1.text, accessibilityLabel: cta_1.text, - onPress: () => handlePressCta(cta_1) + onPress: () => handlePressCta(cta_1, "cta_1") } }; } @@ -182,12 +206,12 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { primaryActionProps: { label: cta_1.text, accessibilityLabel: cta_1.text, - onPress: () => handlePressCta(cta_1) + onPress: () => handlePressCta(cta_1, "cta_1") }, secondaryActionProps: { label: cta_2.text, accessibilityLabel: cta_2.text, - onPress: () => handlePressCta(cta_2) + onPress: () => handlePressCta(cta_2, "cta_2") } }; } @@ -198,7 +222,7 @@ export const ServiceDetailsScreen = ({ route }: ServiceDetailsScreenProps) => { primaryActionProps: { label: ctas.cta_1.text, accessibilityLabel: ctas.cta_1.text, - onPress: () => handlePressCta(ctas.cta_1) + onPress: () => handlePressCta(ctas.cta_1, "cta_1") } }; } diff --git a/ts/features/services/home/components/FeaturedInstitutionList.tsx b/ts/features/services/home/components/FeaturedInstitutionList.tsx index 3cd8581f665..dd440ffe36b 100644 --- a/ts/features/services/home/components/FeaturedInstitutionList.tsx +++ b/ts/features/services/home/components/FeaturedInstitutionList.tsx @@ -5,6 +5,7 @@ import I18n from "../../../../i18n"; import { useIONavigation } from "../../../../navigation/params/AppParamsList"; import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; +import * as analytics from "../../common/analytics"; import { SERVICES_ROUTES } from "../../common/navigation/routes"; import { featuredInstitutionsGet } from "../store/actions"; import { @@ -29,6 +30,11 @@ export const FeaturedInstitutionList = () => { const handlePress = useCallback( ({ fiscal_code, name }: Institution) => { + analytics.trackInstitutionSelected({ + organization_name: name, + source: "featured_organizations" + }); + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { screen: SERVICES_ROUTES.INSTITUTION_SERVICES, params: { diff --git a/ts/features/services/home/components/FeaturedServiceList.tsx b/ts/features/services/home/components/FeaturedServiceList.tsx index 4356f7bbc4d..6d7de854e82 100644 --- a/ts/features/services/home/components/FeaturedServiceList.tsx +++ b/ts/features/services/home/components/FeaturedServiceList.tsx @@ -1,10 +1,12 @@ import React, { useCallback, useMemo } from "react"; import { ListItemHeader, VSpacer } from "@pagopa/io-app-design-system"; import { NonEmptyString } from "@pagopa/ts-commons/lib/strings"; +import { FeaturedService } from "../../../../../definitions/services/FeaturedService"; import I18n from "../../../../i18n"; import { useIONavigation } from "../../../../navigation/params/AppParamsList"; import { useIODispatch, useIOSelector } from "../../../../store/hooks"; import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; +import * as analytics from "../../common/analytics"; import { SERVICES_ROUTES } from "../../common/navigation/routes"; import { featuredServicesGet } from "../store/actions"; import { @@ -28,11 +30,17 @@ export const FeaturedServiceList = () => { useOnFirstRender(() => dispatch(featuredServicesGet.request())); const handlePress = useCallback( - (serviceId: string) => { + ({ id, name, organization_name }: FeaturedService) => { + analytics.trackServiceSelected({ + organization_name: organization_name ?? "", + service_name: name, + source: "featured_services" + }); + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { screen: SERVICES_ROUTES.SERVICE_DETAIL, params: { - serviceId: serviceId as NonEmptyString + serviceId: id as NonEmptyString } }); }, @@ -44,7 +52,7 @@ export const FeaturedServiceList = () => { featuredServices.map(({ organization_name, ...rest }) => ({ ...rest, organizationName: organization_name, - onPress: () => handlePress(rest.id) + onPress: () => handlePress({ organization_name, ...rest }) })), [featuredServices, handlePress] ); diff --git a/ts/features/services/home/screens/ServicesHomeScreen.tsx b/ts/features/services/home/screens/ServicesHomeScreen.tsx index 62fc8baf94b..13cecb727f0 100644 --- a/ts/features/services/home/screens/ServicesHomeScreen.tsx +++ b/ts/features/services/home/screens/ServicesHomeScreen.tsx @@ -24,6 +24,7 @@ import { FeaturedInstitutionList } from "../components/FeaturedInstitutionList"; import { FeaturedServiceList } from "../components/FeaturedServiceList"; import { useInstitutionsFetcher } from "../hooks/useInstitutionsFetcher"; import { featuredInstitutionsGet, featuredServicesGet } from "../store/actions"; +import * as analytics from "../../common/analytics"; const styles = StyleSheet.create({ scrollContentContainer: { @@ -50,13 +51,16 @@ export const ServicesHomeScreen = () => { refreshInstitutions } = useInstitutionsFetcher(); + useOnFirstRender(() => { + analytics.trackServicesHome(); + fetchInstitutions(0); + }); + useTabItemPressWhenScreenActive( () => flatListRef.current?.scrollToOffset({ offset: 0, animated: true }), false ); - useOnFirstRender(() => fetchInstitutions(0)); - useEffect(() => { if (!isFirstRender && isError) { IOToast.error(I18n.t("global.genericError")); @@ -92,7 +96,10 @@ export const ServicesHomeScreen = () => { clearAccessibilityLabel={I18n.t("services.search.input.clear")} placeholder={I18n.t("services.search.input.placeholder")} pressable={{ - onPress: navigateToSearch + onPress: () => { + analytics.trackSearchStart({ source: "search_bar" }); + navigateToSearch(); + } }} /> @@ -115,7 +122,10 @@ export const ServicesHomeScreen = () => { { + analytics.trackSearchStart({ source: "bottom_link" }); + navigateToSearch(); + }} /> @@ -132,20 +142,26 @@ export const ServicesHomeScreen = () => { refreshInstitutions(); }, [dispatch, refreshInstitutions]); - const handleEndReached = useCallback( - () => fetchInstitutions(currentPage + 1), - [currentPage, fetchInstitutions] - ); + const handleEndReached = useCallback(() => { + analytics.trackInstitutionsScroll(); + fetchInstitutions(currentPage + 1); + }, [currentPage, fetchInstitutions]); const navigateToInstitution = useCallback( - (institution: Institution) => + ({ id, name }: Institution) => { + analytics.trackInstitutionSelected({ + organization_name: name, + source: "main_list" + }); + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { screen: SERVICES_ROUTES.INSTITUTION_SERVICES, params: { - institutionId: institution.id, - institutionName: institution.name + institutionId: id, + institutionName: name } - }), + }); + }, [navigation] ); diff --git a/ts/features/services/institution/screens/InstitutionServicesScreen.tsx b/ts/features/services/institution/screens/InstitutionServicesScreen.tsx index c5fbfbdef40..6de98872d80 100644 --- a/ts/features/services/institution/screens/InstitutionServicesScreen.tsx +++ b/ts/features/services/institution/screens/InstitutionServicesScreen.tsx @@ -29,6 +29,7 @@ import { InstitutionServicesFailure } from "../components/InstitutionServicesFai import { ServiceListSkeleton } from "../components/ServiceListSkeleton"; import { useServicesFetcher } from "../hooks/useServicesFetcher"; import { paginatedServicesGet } from "../store/actions"; +import * as analytics from "../../common/analytics"; export type InstitutionServicesScreenRouteParams = { institutionId: string; @@ -77,6 +78,16 @@ export const InstitutionServicesScreen = ({ useOnFirstRender(() => fetchPage(0)); + useOnFirstRender( + () => + analytics.trackInstitutionDetails({ + organization_fiscal_code: institutionId, + organization_name: institutionName, + services_count: data?.count ?? 0 + }), + () => !!data + ); + useEffect(() => { if (!!data && isError) { IOToast.error(I18n.t("global.genericError")); @@ -106,14 +117,21 @@ export const InstitutionServicesScreen = ({ }); const navigateToServiceDetails = useCallback( - (service: ServiceMinified) => + ({ id, name }: ServiceMinified) => { + analytics.trackServiceSelected({ + organization_name: institutionName, + service_name: name, + source: "organization_detail" + }); + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { screen: SERVICES_ROUTES.SERVICE_DETAIL, params: { - serviceId: service.id as ServiceId + serviceId: id as ServiceId } - }), - [navigation] + }); + }, + [institutionName, navigation] ); const handleEndReached = useCallback( diff --git a/ts/features/services/search/screens/SearchScreen.tsx b/ts/features/services/search/screens/SearchScreen.tsx index fc0b430c574..dadba03c280 100644 --- a/ts/features/services/search/screens/SearchScreen.tsx +++ b/ts/features/services/search/screens/SearchScreen.tsx @@ -20,6 +20,8 @@ import { SERVICES_ROUTES } from "../../common/navigation/routes"; import { EmptyState } from "../../common/components/EmptyState"; import { InstitutionListSkeleton } from "../../common/components/InstitutionListSkeleton"; import { ListItemSearchInstitution } from "../../common/components/ListItemSearchInstitution"; +import { useOnFirstRender } from "../../../../utils/hooks/useOnFirstRender"; +import * as analytics from "../../common/analytics"; const MIN_QUERY_LENGTH: number = 3; const LIST_ITEM_HEIGHT: number = 70; @@ -40,6 +42,8 @@ export const SearchScreen = () => { isUpdating } = useInstitutionsFetcher(); + useOnFirstRender(() => analytics.trackSearchPage()); + useEffect(() => { if (isError) { IOToast.error(I18n.t("global.genericError")); @@ -63,6 +67,7 @@ export const SearchScreen = () => { setQuery(text); if (text.length >= MIN_QUERY_LENGTH) { + analytics.trackSearchInput(); fetchPage(0, text); } else { dispatch(searchPaginatedInstitutionsGet.cancel()); @@ -72,18 +77,25 @@ export const SearchScreen = () => { const handleEndReached = useCallback(() => { if (!!data && query.length >= MIN_QUERY_LENGTH) { fetchNextPage(currentPage + 1, query); + analytics.trackSearchResultScroll(); } }, [currentPage, data, fetchNextPage, query]); const navigateToInstitution = useCallback( - (institution: Institution) => + ({ id, name }: Institution) => { + analytics.trackInstitutionSelected({ + organization_name: name, + source: "search_list" + }); + navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { screen: SERVICES_ROUTES.INSTITUTION_SERVICES, params: { - institutionId: institution.id, - institutionName: institution.name + institutionId: id, + institutionName: name } - }), + }); + }, [navigation] ); diff --git a/ts/navigation/components/HeaderFirstLevelHandler.tsx b/ts/navigation/components/HeaderFirstLevelHandler.tsx index 1c22fa87ba1..2876a466f8a 100644 --- a/ts/navigation/components/HeaderFirstLevelHandler.tsx +++ b/ts/navigation/components/HeaderFirstLevelHandler.tsx @@ -16,6 +16,7 @@ import { MainTabParamsList } from "../params/MainTabParamsList"; import ROUTES from "../routes"; import { useIONavigation } from "../params/AppParamsList"; import { isNewPaymentSectionEnabledSelector } from "../../store/reducers/backendStatus"; +import * as analytics from "../../features/services/common/analytics"; type HeaderFirstLevelProps = ComponentProps; type TabRoutes = keyof MainTabParamsList; @@ -120,10 +121,12 @@ export const HeaderFirstLevelHandler = ({ currentRouteName }: Props) => { thirdAction: { icon: "search", accessibilityLabel: I18n.t("global.accessibility.search"), - onPress: () => + onPress: () => { + analytics.trackSearchStart({ source: "header_icon" }); navigation.navigate(SERVICES_ROUTES.SERVICES_NAVIGATOR, { screen: SERVICES_ROUTES.SEARCH - }) + }); + } } }; case ROUTES.PROFILE_MAIN: diff --git a/ts/store/middlewares/analytics.ts b/ts/store/middlewares/analytics.ts index 28cfcb71197..de506f58468 100644 --- a/ts/store/middlewares/analytics.ts +++ b/ts/store/middlewares/analytics.ts @@ -107,8 +107,8 @@ import { updateNotificationInstallationFailure, updateNotificationsInstallationToken } from "../../features/pushNotifications/store/actions/notifications"; +import { trackServicesAction } from "../../features/services/common/analytics"; import { trackContentAction } from "./contentAnalytics"; -import { trackServiceAction } from "./serviceAnalytics"; const trackAction = (mp: NonNullable) => @@ -397,7 +397,7 @@ export const actionTracking = void trackCoBadgeAction(mixpanel)(action); void trackCgnAction(mixpanel)(action); void trackContentAction(mixpanel)(action); - void trackServiceAction(mixpanel)(action); + void trackServicesAction(mixpanel)(action); void trackEuCovidCertificateActions(mixpanel)(action); void trackPaypalOnboarding(mixpanel)(action); void trackZendesk(mixpanel)(action); diff --git a/ts/store/middlewares/serviceAnalytics.ts b/ts/store/middlewares/serviceAnalytics.ts deleted file mode 100644 index e3118ea151a..00000000000 --- a/ts/store/middlewares/serviceAnalytics.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { getType } from "typesafe-actions"; -import { mixpanel } from "../../mixpanel"; -import { Action } from "../actions/types"; -import { loadServicesDetail, loadVisibleServices } from "../actions/services"; -import { loadServiceDetail } from "../../features/services/details/store/actions/details"; -import { - loadServicePreference, - upsertServicePreference -} from "../../features/services/details/store/actions/preference"; - -// Isolated tracker for services actions -export const trackServiceAction = - (mp: NonNullable) => - (action: Action): void => { - switch (action.type) { - case getType(loadServicesDetail): - return mp.track(action.type, { - count: action.payload.length - }); - case getType(loadServiceDetail.failure): - return mp.track(action.type, { - reason: action.payload.error.message - }); - case getType(loadVisibleServices.failure): - return mp.track(action.type, { - reason: action.payload.message - }); - case getType(loadServicePreference.failure): - case getType(upsertServicePreference.failure): - return mp.track(action.type, { - service_id: action.payload.id, - reason: action.payload - }); - case getType(loadVisibleServices.request): - case getType(loadVisibleServices.success): - case getType(loadServiceDetail.request): - case getType(loadServiceDetail.success): - case getType(loadServicePreference.request): - return mp.track(action.type); - case getType(upsertServicePreference.request): - return mp.track(action.type, { - service_id: action.payload.id - }); - case getType(loadServicePreference.success): - case getType(upsertServicePreference.success): - return mp.track(action.type, { - service_id: action.payload.id, - responseStatus: action.payload.kind - }); - } - };