Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework remote promo sheet logic / designs #6085

Merged
merged 13 commits into from
Sep 16, 2024
196 changes: 90 additions & 106 deletions src/components/PromoSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import React, { useCallback, useEffect, useReducer } from 'react';
import { ImageSourcePropType, Dimensions, StatusBar, ImageBackground } from 'react-native';
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
import { ImageSourcePropType, StatusBar, ImageBackground } from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import MaskedView from '@react-native-masked-view/masked-view';
import { SheetActionButton, SheetHandle, SlackSheet } from '@/components/sheet';
import { CampaignKey } from '@/components/remote-promo-sheet/localCampaignChecks';
import { analyticsV2 } from '@/analytics';
import { AccentColorProvider, Box, Inset, Row, Rows, Stack, Text, Bleed, Column, Columns } from '@/design-system';
import { AccentColorProvider, Box, Stack, Text, Bleed, Column, Columns, useForegroundColor, useAccentColor } from '@/design-system';
import { useDimensions } from '@/hooks';
import { sharedCoolModalTopOffset } from '@/navigation/config';
import { useTheme } from '@/theme';
import { IS_IOS, IS_ANDROID } from '@/env';

const MIN_HEIGHT = 740;
import { IS_ANDROID } from '@/env';
import { safeAreaInsetValues } from '@/utils';

type SheetActionButtonProps = {
label: string;
Expand Down Expand Up @@ -54,8 +52,9 @@ export function PromoSheet({
secondaryButtonProps,
items,
}: PromoSheetProps) {
const { width: deviceWidth, height: deviceHeight } = useDimensions();
const { colors } = useTheme();
const { width: deviceWidth, height: deviceHeight } = useDimensions();
const labelTertiary = useForegroundColor('labelTertiary');
const renderedAt = Date.now();
const [activated, activate] = useReducer(() => true, false);

Expand All @@ -82,10 +81,7 @@ export function PromoSheet({
primaryButtonProps.onPress();
}, [activate, campaignKey, primaryButtonProps, renderedAt]);

// We are not using `isSmallPhone` from `useDimensions` here as we
// want to explicitly set a min height.
const isSmallPhone = deviceHeight < MIN_HEIGHT;
const contentHeight = deviceHeight - (!isSmallPhone ? sharedCoolModalTopOffset : 0);
const contentHeight = deviceHeight - safeAreaInsetValues.top;

return (
<SlackSheet
Expand All @@ -99,103 +95,91 @@ export function PromoSheet({
<StatusBar barStyle="light-content" />
<AccentColorProvider color={backgroundColor}>
<Box background="accent" style={{ height: contentHeight }} testID={campaignKey}>
{/* @ts-ignore */}
<Box as={ImageBackground} height="full" source={backgroundImage}>
<Rows>
<Row>
<Stack space={{ custom: isSmallPhone ? 46 : 54 }}>
<Box>
<Box height={{ custom: isSmallPhone ? 195 : 265 }} width="full">
{/* @ts-ignore */}
<Box
as={ImageBackground}
height={{
custom: deviceWidth / headerImageAspectRatio,
}}
marginTop={{ custom: isSmallPhone ? -70 : 0 }}
source={headerImage}
width="full"
>
{/* @ts-ignore */}
<SheetHandle alignSelf="center" color={sheetHandleColor} style={{ marginTop: isSmallPhone ? 75 : 5 }} />
</Box>
</Box>
<Stack alignHorizontal="center" space={{ custom: 13 }}>
<Text color="labelSecondary" size="15pt" weight="heavy">
{subHeader}
</Text>
<Text color="label" size="30pt" weight="heavy">
{header}
</Text>
</Stack>
</Box>
<Inset horizontal={{ custom: 43.5 }}>
<Stack space={isSmallPhone ? '24px' : '36px'}>
{items.map(item => (
<Columns key={item.title} space={{ custom: 13 }}>
<Column width="content">
<MaskedView
maskElement={
<Box paddingTop={IS_ANDROID ? '6px' : undefined}>
<Text align="center" color="accent" size="30pt" weight="bold">
{item.icon}
</Text>
</Box>
}
style={{ width: 42 }}
>
<Box
as={LinearGradient}
colors={item.gradient}
end={{ x: 0.5, y: 1 }}
height={{ custom: 50 }}
marginTop="-10px"
start={{ x: 0, y: 0 }}
width="full"
/>
</MaskedView>
</Column>
<Bleed top="3px">
<Stack space="12px">
<Text color="label" size="17pt" weight="bold">
{item.title}
<Box>
<Box
as={ImageBackground}
height={{
custom: deviceWidth / headerImageAspectRatio,
}}
source={headerImage}
width="full"
>
<SheetHandle alignSelf="center" color={sheetHandleColor} style={{ marginTop: 5 }} />
</Box>
</Box>
<Box paddingVertical="28px" height={{ custom: deviceHeight - deviceWidth / headerImageAspectRatio - 58 }} flexGrow={1}>
<Box alignItems="center" paddingHorizontal="20px" paddingBottom="20px" gap={14}>
<Text color="labelSecondary" size="15pt" align="center" weight="heavy">
{subHeader}
</Text>
<Text color="label" align="center" size="30pt" weight="heavy">
{header}
</Text>
</Box>
<Box flexGrow={1} paddingHorizontal="20px" paddingVertical="24px">
<Stack space="24px">
{items.map(item => (
<Columns key={item.title} space={{ custom: 13 }}>
<Column width="content">
<MaskedView
maskElement={
<Box paddingTop={IS_ANDROID ? '6px' : undefined}>
<Text align="center" color="accent" size="30pt" weight="bold">
{item.icon}
</Text>
<Text color="labelSecondary" size="15pt" weight="medium">
{item.description}
</Text>
</Stack>
</Bleed>
</Columns>
))}
</Stack>
</Inset>
</Box>
}
style={{ width: 42 }}
>
<Box
as={LinearGradient}
colors={item.gradient}
end={{ x: 0.5, y: 1 }}
height={{ custom: 50 }}
marginTop="-10px"
start={{ x: 0, y: 0 }}
width="full"
/>
</MaskedView>
</Column>
<Bleed top="3px">
<Stack space="12px">
<Text color="label" size="17pt" weight="bold">
{item.title}
</Text>
<Text color="labelSecondary" size="15pt" weight="medium">
{item.description}
</Text>
</Stack>
</Bleed>
</Columns>
))}
</Stack>
</Box>
<Box paddingHorizontal="20px">
<Stack space="12px">
<SheetActionButton
color={primaryButtonProps.color || accentColor}
label={primaryButtonProps.label}
lightShadows
onPress={primaryButtonOnPress}
textColor={primaryButtonProps.textColor}
textSize="large"
weight="heavy"
/>
<SheetActionButton
color={secondaryButtonProps.color || colors.transparent}
isTransparent
label={secondaryButtonProps.label}
onPress={secondaryButtonProps.onPress || (() => {})}
textColor={secondaryButtonProps.textColor || labelTertiary}
textSize="large"
weight="heavy"
/>
</Stack>
</Row>
<Row height="content">
<Inset bottom={isSmallPhone && IS_IOS ? '24px' : '42px (Deprecated)'} horizontal="19px (Deprecated)">
<Stack space="12px">
<SheetActionButton
color={primaryButtonProps.color || accentColor}
label={primaryButtonProps.label}
lightShadows
onPress={primaryButtonOnPress}
textColor={primaryButtonProps.textColor || backgroundColor}
textSize="large"
weight="heavy"
/>
<SheetActionButton
color={secondaryButtonProps.color || colors.transparent}
isTransparent
label={secondaryButtonProps.label}
onPress={secondaryButtonProps.onPress || (() => {})}
textColor={secondaryButtonProps.textColor || accentColor}
textSize="large"
weight="heavy"
/>
</Stack>
</Inset>
</Row>
</Rows>
</Box>
</Box>
</Box>
</Box>
</AccentColorProvider>
Expand Down
47 changes: 34 additions & 13 deletions src/components/remote-promo-sheet/RemotePromoSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import c from 'chroma-js';
import React, { useCallback, useEffect } from 'react';
import { useRoute, RouteProp } from '@react-navigation/native';
import { get } from 'lodash';
Expand All @@ -14,6 +15,8 @@ import { Language } from '@/languages';
import { useAccountSettings } from '@/hooks';
import { remotePromoSheetsStore } from '@/state/remotePromoSheets/remotePromoSheets';
import { RootStackParamList } from '@/navigation/types';
import { Colors } from '@/styles';
import { getHighContrastColor } from '@/__swaps__/utils/swaps';

const DEFAULT_HEADER_HEIGHT = 285;
const DEFAULT_HEADER_WIDTH = 390;
Expand All @@ -30,6 +33,18 @@ const enum ButtonType {
External = 'External',
}

const getHexOrThemeColor = (colors: Colors, hexOrThemeString: string | null | undefined, fallbackColor: string) => {
if (!hexOrThemeString) {
return get(colors, fallbackColor);
}

if (c.valid(hexOrThemeString)) {
return hexOrThemeString;
}

return get(colors, hexOrThemeString) ?? get(colors, fallbackColor);
};

const getKeyForLanguage = (key: string, promoSheet: any, language: Language) => {
if (!promoSheet) {
return '';
Expand All @@ -48,26 +63,21 @@ const getKeyForLanguage = (key: string, promoSheet: any, language: Language) =>
};

export function RemotePromoSheet() {
const { colors } = useTheme();
const { colors, isDarkMode } = useTheme();
const { goBack, navigate } = useNavigation();
const { params } = useRoute<RouteProp<RootStackParamList, 'RemotePromoSheet'>>();
const { campaignId, campaignKey } = params;
const { language } = useAccountSettings();

useEffect(() => {
remotePromoSheetsStore.setState({
isShown: true,
lastShownTimestamp: Date.now(),
});

return () => {
remotePromoSheetsStore.setState({
isShown: false,
});
};
}, []);

const { data, error } = usePromoSheetQuery(
const { data } = usePromoSheetQuery(
{
id: campaignId,
},
Expand Down Expand Up @@ -100,7 +110,8 @@ export function RemotePromoSheet() {
);
}, [goBack, navigate, data?.promoSheet]);

if (!data?.promoSheet || error) {
if (!data?.promoSheet) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only shouldn't show if we don't have the data, regardless of error

goBack();
return null;
}

Expand All @@ -116,9 +127,17 @@ export function RemotePromoSheet() {
secondaryButtonProps,
} = data.promoSheet;

const accentColor = (colors as { [key: string]: any })[accentColorString as string] ?? accentColorString;
const backgroundColor = (colors as { [key: string]: any })[backgroundColorString as string] ?? backgroundColorString;
const sheetHandleColor = (colors as { [key: string]: any })[sheetHandleColorString as string] ?? sheetHandleColorString;
const accentColor = getHexOrThemeColor(colors, accentColorString, 'appleBlue');
const backgroundColor = getHexOrThemeColor(colors, backgroundColorString, 'white');
const sheetHandleColor = getHexOrThemeColor(colors, sheetHandleColorString, 'whiteLabel');
const primaryButtonBgColor = getHexOrThemeColor(colors, primaryButtonProps.color, 'appleBlue');
const primaryButtonTextColor = getHexOrThemeColor(colors, primaryButtonProps.textColor, 'whiteLabel');
const secondaryButtonBgColor = getHexOrThemeColor(colors, secondaryButtonProps.color, 'transparent');
const secondaryButtonTextColor = getHexOrThemeColor(
colors,
secondaryButtonProps.textColor || getHighContrastColor(backgroundColor)[isDarkMode ? 'dark' : 'light'],
'whiteLabel'
);

const backgroundSignedImageUrl = backgroundImage?.url ? maybeSignUri(backgroundImage.url) : undefined;
const headerSignedImageUrl = headerImage?.url ? maybeSignUri(headerImage.url) : undefined;
Expand All @@ -136,13 +155,15 @@ export function RemotePromoSheet() {
subHeader={getKeyForLanguage('subHeader', data.promoSheet, language as Language)}
primaryButtonProps={{
...primaryButtonProps,
...(primaryButtonProps.color ? { color: get(colors, primaryButtonProps.color) } : {}),
...(primaryButtonProps.textColor ? { textColor: get(colors, primaryButtonProps.textColor) } : {}),
color: primaryButtonBgColor,
textColor: primaryButtonTextColor,
label: getKeyForLanguage('primaryButtonProps.label', data.promoSheet, language as Language),
onPress: getButtonForType(data.promoSheet.primaryButtonProps.type),
}}
secondaryButtonProps={{
...secondaryButtonProps,
color: secondaryButtonBgColor,
textColor: secondaryButtonTextColor,
label: getKeyForLanguage('secondaryButtonProps.label', data.promoSheet, language as Language),
onPress: goBack,
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { isTargetedVersionOrNewer } from '../isTargetedVersionOrNewer';
import * as DeviceInfo from 'react-native-device-info';

jest.mock('react-native-device-info', () => ({
getVersion: jest.fn(),
}));

describe('isTargetedVersionOrNewer', () => {
afterEach(() => {
jest.resetAllMocks();
});

it('should return true when current app version is newer than version to check', async () => {
(DeviceInfo.getVersion as jest.Mock).mockReturnValue('2.0.0');
const result = await isTargetedVersionOrNewer('1.9.0');
expect(result).toBe(true);
});

it('should return false when current app version is older than version to check', async () => {
(DeviceInfo.getVersion as jest.Mock).mockReturnValue('1.8.0');
const result = await isTargetedVersionOrNewer('1.9.0');
expect(result).toBe(false);
});

it('should return true when versions are equal', async () => {
(DeviceInfo.getVersion as jest.Mock).mockReturnValue('1.9.0');
const result = await isTargetedVersionOrNewer('1.9.0');
expect(result).toBe(true);
});

it('should handle patch versions correctly', async () => {
(DeviceInfo.getVersion as jest.Mock).mockReturnValue('1.9.1');
const result = await isTargetedVersionOrNewer('1.9.0');
expect(result).toBe(true);
});

it('should handle versions with different number of parts', async () => {
(DeviceInfo.getVersion as jest.Mock).mockReturnValue('2.0');
const result = await isTargetedVersionOrNewer('1.9.9');
expect(result).toBe(true);
});

it('should return true when versions are equal but have different number of parts', async () => {
(DeviceInfo.getVersion as jest.Mock).mockReturnValue('2.0.0');
const result = await isTargetedVersionOrNewer('2.0');
expect(result).toBe(true);
});

it('should return false when current app version is older with different number of parts', async () => {
(DeviceInfo.getVersion as jest.Mock).mockReturnValue('1.9');
const result = await isTargetedVersionOrNewer('2.0.0');
expect(result).toBe(false);
});
});
Loading
Loading