diff --git a/App.tsx b/App.tsx index dae6c03..337f8e9 100755 --- a/App.tsx +++ b/App.tsx @@ -18,11 +18,12 @@ import { Tariff } from "./types/tariff"; import { CloseButton } from "./components/header/closeButton"; import { DetailHeader } from "./components/detail/detailHeader"; -import { useFetchAppData } from "./hooks/fetchAppData"; +import { useFetchAppData } from "./hooks/usefetchAppData"; import { useCustomFonts } from "./hooks/customFont"; import { scale } from "react-native-size-matters"; import { FeedbackView } from "./screens/feedbackView"; import { ToastNotification } from "./components/detail/feedbackView/toastNotification"; +import { useAopMetrics } from "./hooks/useAppMetrics"; const queryClient = new QueryClient(); const RootStack = createStackNavigator(); @@ -46,7 +47,7 @@ function AppWrapper(): JSX.Element { useEffect(() => { const subscription = AppState.addEventListener( "change", - onAppStateChange + onAppStateChange, ); return () => { @@ -55,6 +56,7 @@ function AppWrapper(): JSX.Element { }, [onAppStateChange]); useFetchAppData(); + useAopMetrics(); const fontLoaded = useCustomFonts(); if (!fontLoaded) { diff --git a/app.json b/app.json index 13a7595..2fa4095 100755 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Ladefuchs", "slug": "ladefuchs", - "version": "2.2.0", + "version": "2.2.2", "orientation": "portrait", "scheme": "com.ladefuchs.app", "icon": "./assets/fuchs/app_icon.png", @@ -14,7 +14,7 @@ }, "assetBundlePatterns": ["**/*"], "ios": { - "buildNumber": "17", + "buildNumber": "5", "supportsTablet": false, "jsEngine": "jsc", "bundleIdentifier": "app.ladefuchs.Ladefuchs", @@ -33,7 +33,7 @@ "resizeMode": "contain", "backgroundColor": "#F3EEE2" }, - "versionCode": 237, + "versionCode": 238, "adaptiveIcon": { "foregroundImage": "./assets/fuchs/android_logo.png", "backgroundColor": "#F3EEE2" diff --git a/components/detail/detailHeader.tsx b/components/detail/detailHeader.tsx index e979a20..aa37489 100644 --- a/components/detail/detailHeader.tsx +++ b/components/detail/detailHeader.tsx @@ -23,7 +23,10 @@ export function DetailHeader({ tariff, navigation }: Props): JSX.Element { {tariff.providerName} - navigation.goBack()} /> + navigation.goBack()} + backgroundColor={colors.ladefuchsDarkBackground} + /> ); diff --git a/components/header/appHeader.tsx b/components/header/appHeader.tsx index b8fd661..2422cd7 100644 --- a/components/header/appHeader.tsx +++ b/components/header/appHeader.tsx @@ -19,11 +19,6 @@ export function AppHeader(): JSX.Element { const navigation = useNavigation(); const reloadBanner = useAppStore((state) => state.reloadBanner); - const handleLongPress = () => { - console.log("handleLongPress reloadBanner"); - reloadBanner(); - }; - return ( reloadBanner()} > diff --git a/components/header/closeButton.tsx b/components/header/closeButton.tsx index 8c0cb65..fc046d5 100644 --- a/components/header/closeButton.tsx +++ b/components/header/closeButton.tsx @@ -1,27 +1,38 @@ import { TouchableOpacity, ViewStyle, View } from "react-native"; import Svg, { Path } from "react-native-svg"; import { colors } from "../../theme"; +import React from "react"; +import { scale } from "react-native-size-matters"; interface Props { onPress: () => void; + backgroundColor?: string; style?: ViewStyle; } -export function CloseButton({ onPress, style }: Props): JSX.Element { +export function CloseButton({ + onPress, + style, + backgroundColor = colors.ladefuchsDarkGrayBackground, +}: Props): JSX.Element { + const size = scale(19); return ( - + { } } -// todo error handling, and forward errors and show in the UI export async function sendFeedback(request: FeedbackRequest): Promise { const response = await fetchWithTimeout(`${apiPath}/v3/feedback`, { method: "POST", @@ -154,7 +164,7 @@ export async function sendFeedback(request: FeedbackRequest): Promise { }, body: JSON.stringify(request), }); - if (response.status > 299) { + if (!response.ok) { throw Error("could not send feedback, got an bad status code"); } } @@ -164,6 +174,75 @@ const storageSet = { chargeConditionData: "chargeConditionData", }; +export async function postBannerImpression( + banner: Banner | null, +): Promise { + if (isDebug) { + return; + } + + if (!banner?.identifier) { + return; + } + const response = await fetchWithTimeout( + `${apiPath}/v3/banners/impression`, + { + method: "POST", + headers: { + ...authHeader.headers, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + bannerId: banner.identifier, + platform: Platform.OS, + } satisfies ImpressionRequest), + }, + ); + if (!response.ok) { + throw new Error("Network response was not ok"); + } +} + +export async function postAppMetric(): Promise { + if (isDebug) { + return; + } + const cacheKey = "appMetric"; + const cache = await retrieveFromStorage(cacheKey); + + if (cache?.lastUpdated) { + const updatedDevice = Date.parse(cache?.lastUpdated); + const oneHourInMs = getMinutes(20); + if (Date.now() - updatedDevice < oneHourInMs) { + return; + } + } + + const response = await fetchWithTimeout(`${apiPath}/v3/app/metrics`, { + method: "POST", + headers: { + ...authHeader.headers, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + deviceId: cache?.deviceId ?? null, + version: appVersionNumber(), + platform: Platform.OS, + } satisfies AppMetricsRequest), + }); + if (!response.ok) { + throw new Error("Network response was not ok"); + } + const json: AppMetricResponse = await response.json(); + + await saveToStorage(cacheKey, { + deviceId: json.deviceId, + lastUpdated: new Date().toISOString(), + } satisfies AppMetricCache); +} + export async function getBanners({ writeToCache, }: { diff --git a/functions/util.ts b/functions/util.ts index 49d82fe..b8257ee 100644 --- a/functions/util.ts +++ b/functions/util.ts @@ -1,3 +1,15 @@ +import Constants from "expo-constants"; + +export const isDebug = __DEV__; + +export function getMinutes(minutes: number): number { + return minutes * 60 * 1000; +} + +export function appVersionNumber(): number { + return parseInt(Constants.expoConfig.version.replaceAll(".", "")); +} + export function fill(list1: T[], list2: T[]): [T[], T[]] { const len1 = list1.length; const len2 = list2.length; @@ -43,7 +55,7 @@ function compose(...functions: ((arg: T) => T)[]): (arg: T) => T { export const shuffleAndPickOne = compose( repeatItemsByFrequency, shuffle, - pickRandom + pickRandom, ); function repeatNTimes(element: T, times: number): T[] { @@ -51,7 +63,7 @@ function repeatNTimes(element: T, times: number): T[] { } export function repeatItemsByFrequency( - items: T[] + items: T[], ): T[] { return items.flatMap((item) => repeatNTimes(item, item.frequency)); } @@ -72,7 +84,7 @@ export function hyphenText(input: string): string { export async function fetchWithTimeout( url: string, options: RequestInit = null, - timeout = 2800 + timeout = 2700, ) { const controller = new AbortController(); options.signal = controller.signal; diff --git a/hooks/useAppMetrics.ts b/hooks/useAppMetrics.ts new file mode 100644 index 0000000..a312dce --- /dev/null +++ b/hooks/useAppMetrics.ts @@ -0,0 +1,54 @@ +import { useMutation } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { AppStateStatus, AppState } from "react-native"; +import { postAppMetric, postBannerImpression } from "../functions/api"; +import { useShallow } from "zustand/react/shallow"; +import { useAppStore } from "../state/state"; + +export function useAopMetrics() { + const [banner] = useAppStore(useShallow((state) => [state.banner])); + + const sendBannerImpression = useMutation({ + mutationKey: [banner?.imageUrl ?? ""], + mutationFn: async () => await postBannerImpression(banner), + retry: 1, + }); + + useEffect(() => { + if (banner?.bannerType !== "ladefuchs") { + return; + } + if (!sendBannerImpression.isIdle) { + return; + } + sendBannerImpression.mutateAsync(); + }, [banner]); + + const sendAppMetric = useMutation({ + mutationFn: async () => await postAppMetric(), + retry: 1, + }); + + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + if (nextAppState !== "active") { + return; + } + if (!sendAppMetric.isIdle) { + return; + } + setTimeout(async () => { + await sendAppMetric.mutateAsync(); + sendAppMetric.reset(); + }, 1000); + }; + const subscription = AppState.addEventListener( + "change", + handleAppStateChange, + ); + + return () => { + subscription.remove(); + }; + }, [sendAppMetric]); +} diff --git a/hooks/fetchAppData.ts b/hooks/usefetchAppData.ts similarity index 99% rename from hooks/fetchAppData.ts rename to hooks/usefetchAppData.ts index c44aa28..2774948 100644 --- a/hooks/fetchAppData.ts +++ b/hooks/usefetchAppData.ts @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useEffect } from "react"; import { getAllChargeConditions, getBanners } from "../functions/api"; + import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "../state/state"; @@ -13,7 +14,7 @@ export function useFetchAppData(): void { state.operators, state.setBanners, state.ladefuchsBanners, - ]) + ]), ); const allChargeConditionsQuery = useQuery({ queryKey: ["appChargeConditions"], diff --git a/package-lock.json b/package-lock.json index c5711dc..6281dd6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11161,9 +11161,9 @@ "license": "MIT" }, "node_modules/fast-xml-parser": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz", - "integrity": "sha512-kLY3jFlwIYwBNDojclKsNAC12sfD6NwW74QB2CoNGPvtVxjliYehVunB3HYyNi+n4Tt1dAcgwYvmKF/Z18flqg==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", "funding": [ { "type": "github", @@ -11174,7 +11174,6 @@ "url": "https://paypal.me/naturalintelligence" } ], - "license": "MIT", "dependencies": { "strnum": "^1.0.5" }, diff --git a/screens/feedbackView.tsx b/screens/feedbackView.tsx index 2b45544..e9ba196 100644 --- a/screens/feedbackView.tsx +++ b/screens/feedbackView.tsx @@ -11,9 +11,15 @@ import { colors, styles as themeStyle } from "../theme"; import { DetailLogos } from "../components/detail/detailLogos"; import { LadefuchsButton } from "../components/detail/ladefuchsButton"; import { Tariff } from "../types/tariff"; -import { TariffCondition } from "../types/conditions"; +import { ChargeMode, TariffCondition } from "../types/conditions"; import { ScaledSheet } from "react-native-size-matters"; -import { FeedbackContext, FeedbackRequest } from "../types/feedback"; +import { + FeedbackContext, + FeedbackRequest, + OtherFeedbackRequest, + WrongPriceFeedbackAttributes, + WrongPriceRequest, +} from "../types/feedback"; import { scale } from "react-native-size-matters"; import { PriceBox } from "../components/detail/priceBox"; @@ -67,22 +73,24 @@ export function FeedbackView(): JSX.Element { const requests = []; - const acWrongPrice = checkPriceAndPushRequest({ + const acWrongPrice = buildWrongPriceRequest({ displayedPrice: acTariffCondition.pricePerKwh, actualPrice: acPriceCounter.value, context, noteText, + chargeType: "ac", }); if (acWrongPrice) { requests.push(acWrongPrice); } - const dcWrongPrice = checkPriceAndPushRequest({ + const dcWrongPrice = buildWrongPriceRequest({ displayedPrice: dcTariffCondition.pricePerKwh, actualPrice: dcPriceCounter.value, context, noteText, + chargeType: "dc", }); if (dcWrongPrice) { requests.push(dcWrongPrice); @@ -95,7 +103,7 @@ export function FeedbackView(): JSX.Element { type: "otherFeedback", attributes: { notes: noteText }, }, - }); + } satisfies OtherFeedbackRequest); } return requests; }; @@ -119,7 +127,7 @@ export function FeedbackView(): JSX.Element { }, 500); } catch (error) { setSendButtonText("Senden"); - console.log("sedning feedback", error); + console.log("sending feedback", error); setDisableSendButton(false); Toast.show({ type: "error", @@ -255,16 +263,18 @@ const feedbackthemeStyle = ScaledSheet.create({ }, }); -function checkPriceAndPushRequest({ +function buildWrongPriceRequest({ displayedPrice, actualPrice, context, noteText, + chargeType, }: { displayedPrice: number | null; actualPrice: number; - context; - noteText; + context: FeedbackContext; + noteText: string; + chargeType: ChargeMode; }) { if ( displayedPrice && @@ -278,9 +288,10 @@ function checkPriceAndPushRequest({ notes: noteText, displayedPrice, actualPrice, + chargeType, }, }, - }; + } satisfies WrongPriceRequest; } return null; diff --git a/state/state.ts b/state/state.ts index cb307dd..caf41d9 100644 --- a/state/state.ts +++ b/state/state.ts @@ -90,7 +90,7 @@ export const useAppStore = create((set, get) => { (ladefuchsBannerIndex + 1) % ladefuchsBanners.length; newBanner = ladefuchsBanners[ladefuchsBannerIndex]; } while (newBanner.imageUrl === banner?.imageUrl); - set(() => ({ banner: newBanner })); + set(() => ({ banner: { ...newBanner, bannerType: "ladefuchs" } })); }, }; }); @@ -116,6 +116,7 @@ function selectLadefuchsBanner({ return null; } return { + identifier: banner.identifier, bannerType: "ladefuchs", affiliateLinkUrl: banner.affiliateLinkUrl, imageUrl: banner.imageUrl, diff --git a/types/banner.ts b/types/banner.ts index 18f6d95..74ac8fb 100644 --- a/types/banner.ts +++ b/types/banner.ts @@ -1,3 +1,5 @@ +import { Platform, PlatformOSType } from "react-native"; + export interface LadefuchsBanner extends Banner { identifier: string; frequency: number; @@ -6,9 +8,15 @@ export interface LadefuchsBanner extends Banner { } export interface Banner { + identifier: string; affiliateLinkUrl: string; imageUrl: string; bannerType: BannerType; } export type BannerType = "ladefuchs" | "chargePrice"; + +export interface ImpressionRequest { + bannerId: string; + platform: PlatformOSType; +} diff --git a/types/feedback.ts b/types/feedback.ts index 08c92cb..7e5a74b 100644 --- a/types/feedback.ts +++ b/types/feedback.ts @@ -1,3 +1,5 @@ +import { ChargeMode } from "./conditions"; + export interface FeedbackContext { operatorId: string; tariffId: string; @@ -9,6 +11,7 @@ export interface WrongPriceFeedbackAttributes { notes: string; displayedPrice: number; actualPrice: number; + chargeType: ChargeMode; } export interface OtherFeedbackAttributes { @@ -17,18 +20,20 @@ export interface OtherFeedbackAttributes { export type FeedbackType = "wrongPriceFeedback" | "otherFeedback"; -export type FeedbackRequest = - | { - context: FeedbackContext; - request: { - type: "wrongPriceFeedback"; - attributes: WrongPriceFeedbackAttributes; - }; - } - | { - context: FeedbackContext; - request: { - type: "otherFeedback"; - attributes: OtherFeedbackAttributes; - }; - }; +export type WrongPriceRequest = { + context: FeedbackContext; + request: { + type: "wrongPriceFeedback"; + attributes: WrongPriceFeedbackAttributes; + }; +}; + +export type OtherFeedbackRequest = { + context: FeedbackContext; + request: { + type: "otherFeedback"; + attributes: OtherFeedbackAttributes; + }; +}; + +export type FeedbackRequest = WrongPriceRequest | OtherFeedbackRequest; diff --git a/types/metrics.ts b/types/metrics.ts new file mode 100644 index 0000000..d5b5958 --- /dev/null +++ b/types/metrics.ts @@ -0,0 +1,16 @@ +import { PlatformOSType } from "react-native"; + +export interface AppMetricsRequest { + deviceId: string | null; + platform: PlatformOSType; + version: number; +} + +export interface AppMetricResponse { + deviceId: string; +} + +export interface AppMetricCache { + deviceId: string | null; + lastUpdated: string; +}