teamdigitale/italia-app

View on GitHub
ts/features/bonus/common/components/BonusInformationComponent.tsx

Summary

Maintainability
D
1 day
Test Coverage
import * as AR from "fp-ts/lib/Array";
import { constNull, pipe } from "fp-ts/lib/function";
import * as O from "fp-ts/lib/Option";
import * as React from "react";
import { ComponentProps } from "react";
import { Image } from "react-native";
import {
  Body,
  ButtonSolidProps,
  ContentWrapper,
  GradientBottomActions,
  H2,
  IOSpacer,
  IOSpacingScale,
  IOVisualCostants,
  LabelLink,
  VSpacer,
  buttonSolidHeight
} from "@pagopa/io-app-design-system";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
  Easing,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
  withTiming
} from "react-native-reanimated";
import { BonusAvailable } from "../../../../../definitions/content/BonusAvailable";
import { BonusAvailableContent } from "../../../../../definitions/content/BonusAvailableContent";
import { LightModalContext } from "../../../../components/ui/LightModal";
import I18n from "../../../../i18n";
import customVariables from "../../../../theme/variables";
import { getRemoteLocale } from "../../../messages/utils/messages";
import { maybeNotNullyString } from "../../../../utils/strings";
import { Markdown } from "../../../../components/ui/Markdown/Markdown";
import { RNavScreenWithLargeHeader } from "../../../../components/ui/RNavScreenWithLargeHeader";
import { useHeaderSecondLevel } from "../../../../hooks/useHeaderSecondLevel";
import TosBonusComponent from "./TosBonusComponent";

type OwnProps = {
  onBack?: () => void;
  bonus: BonusAvailable;
  onConfirm?: () => void;
  onCancel?: () => void;
  primaryCtaText: string;
  secondaryAction?: SecondaryAction;
};

type SecondaryAction = { type: "back"; text: string };

type Props = OwnProps &
  Pick<
    ComponentProps<typeof RNavScreenWithLargeHeader>,
    "contextualHelp" | "contextualHelpMarkdown" | "faqCategories"
  >;

const CSS_STYLE = `
body {
  font-size: ${customVariables.fontSizeBase}px;
  color: ${customVariables.textColorDark}
}

h4 {
  font-size: ${customVariables.fontSize2}px;
}
`;

// for long content markdown computed height should be not enough
const extraMarkdownBodyHeight = 20;

const getTosFooter = (
  maybeBonusTos: O.Option<string>,
  maybeRegulationUrl: O.Option<{ url: string; name: string }>,
  handleModalPress: (tos: string) => void,
  ctaText: string
) =>
  pipe(
    maybeBonusTos,
    O.fold(
      () => null,
      bT =>
        pipe(
          maybeRegulationUrl,
          O.fold(
            () => (
              // if tos is defined and the regolation url is not defined
              // return the link (BONUS VACANZE)
              <>
                <Body color="bluegreyDark">
                  {I18n.t("bonus.bonusVacanze.advice")}
                </Body>
                <LabelLink
                  weight={"Semibold"}
                  numberOfLines={1}
                  onPress={() => handleModalPress(bT)}
                >
                  {I18n.t("bonus.tos.title")}
                </LabelLink>
              </>
            ),
            // if tos and regulation url is defined
            // return a markdown footer including both links reference (BPD)
            rU => (
              <Markdown
                cssStyle={CSS_STYLE}
                extraBodyHeight={extraMarkdownBodyHeight}
              >
                {I18n.t("bonus.termsAndConditionFooter", {
                  ctaText,
                  regulationLink: rU.url,
                  tosUrl: bT
                })}
              </Markdown>
            )
          )
        )
    )
  );

// value is defined the height of the image
const imageHeight: number = 270;

const gradientSafeArea: IOSpacingScale = 80;
const contentEndMargin: IOSpacingScale = 32;
const spaceBetweenActions: IOSpacer = 24;

/**
 * A screen to explain how the bonus activation works and how it will be assigned
 */
const BonusInformationComponent = (props: Props) => {
  const { showModal, hideModal } = React.useContext(LightModalContext);
  const bonusType = props.bonus;
  const bonusTypeLocalizedContent: BonusAvailableContent =
    bonusType[getRemoteLocale()];
  const safeAreaInsets = useSafeAreaInsets();

  const gradientOpacity = useSharedValue(1);
  const scrollTranslationY = useSharedValue(0);

  const bottomMargin: number = React.useMemo(
    () =>
      safeAreaInsets.bottom === 0
        ? IOVisualCostants.appMarginDefault
        : safeAreaInsets.bottom,
    [safeAreaInsets]
  );

  const hasSecondaryButton = props.secondaryAction !== undefined;

  const buttonsSolidHeight = hasSecondaryButton
    ? buttonSolidHeight * 2
    : buttonSolidHeight;

  const safeBottomAreaHeight: number = React.useMemo(
    () => bottomMargin + buttonsSolidHeight + contentEndMargin,
    [bottomMargin, buttonsSolidHeight]
  );

  const gradientAreaHeight: number = React.useMemo(
    () => bottomMargin + buttonsSolidHeight + gradientSafeArea,
    [bottomMargin, buttonsSolidHeight]
  );

  useHeaderSecondLevel({
    title: bonusTypeLocalizedContent.title || "",
    scrollValues: {
      triggerOffset: imageHeight,
      contentOffsetY: scrollTranslationY
    },
    supportRequest: true
  });

  const footerGradientOpacityTransition = useAnimatedStyle(() => ({
    opacity: withTiming(gradientOpacity.value, {
      duration: 200,
      easing: Easing.ease
    })
  }));

  const cancelButtonProps: ButtonSolidProps = {
    label: I18n.t("global.buttons.cancel"),
    fullWidth: true,
    color: "danger",
    accessibilityLabel: I18n.t("global.buttons.cancel"),
    onPress: props.onCancel ?? constNull
  };
  const requestButtonProps: ButtonSolidProps = {
    label: props.primaryCtaText,
    testID: "activate-bonus-button",
    fullWidth: true,
    accessibilityLabel: props.primaryCtaText,
    onPress: props.onConfirm ?? constNull
  };
  const backButtonProps = props.secondaryAction
    ? {
        label: props.secondaryAction.text,
        fullWidth: true,
        accessibilityLabel: props.secondaryAction.text,
        onPress: props.onBack ?? constNull
      }
    : undefined;

  const handleModalPress = (tos: string) =>
    showModal(<TosBonusComponent tos_url={tos} onClose={hideModal} />);

  // bonus rules url should be the first one in the urls list
  const maybeRegulationUrl = pipe(
    bonusTypeLocalizedContent.urls,
    O.fromNullable,
    O.chain(urls => AR.lookup(0, [...urls]))
  );

  const maybeBonusTos = maybeNotNullyString(bonusTypeLocalizedContent.tos_url);
  const maybeHeroImage = maybeNotNullyString(bonusType.hero_image);

  const scrollHandler = useAnimatedScrollHandler(
    ({ contentOffset, layoutMeasurement, contentSize }) => {
      // eslint-disable-next-line functional/immutable-data
      scrollTranslationY.value = contentOffset.y;

      const isEndReached =
        Math.floor(layoutMeasurement.height + contentOffset.y) >=
        Math.floor(contentSize.height);

      // eslint-disable-next-line functional/immutable-data
      gradientOpacity.value = isEndReached ? 0 : 1;
    }
  );

  return (
    <>
      <Animated.ScrollView
        contentContainerStyle={{
          paddingBottom: safeBottomAreaHeight,
          flexGrow: 1
        }}
        onScroll={scrollHandler}
        scrollEventThrottle={16}
        snapToOffsets={[0, imageHeight]}
        snapToEnd={false}
        decelerationRate="normal"
      >
        {O.isSome(maybeHeroImage) && (
          <>
            <Image
              accessibilityIgnoresInvertColors
              source={{ uri: maybeHeroImage.value }}
              style={{
                width: "100%",
                height: imageHeight,
                resizeMode: "stretch"
              }}
            />
            <VSpacer size={24} />
          </>
        )}
        <ContentWrapper>
          <H2 accessibilityRole="header">{bonusTypeLocalizedContent.title}</H2>
          <VSpacer size={16} />
          <Markdown
            cssStyle={CSS_STYLE}
            extraBodyHeight={extraMarkdownBodyHeight}
          >
            {bonusTypeLocalizedContent.subtitle +
              "\n" +
              bonusTypeLocalizedContent.content}
          </Markdown>
          <VSpacer size={40} />
          {getTosFooter(
            maybeBonusTos,
            maybeRegulationUrl,
            handleModalPress,
            props.primaryCtaText
          )}
        </ContentWrapper>
      </Animated.ScrollView>
      <GradientBottomActions
        primaryActionProps={
          props.onConfirm ? { ...requestButtonProps } : { ...cancelButtonProps }
        }
        secondaryActionProps={backButtonProps}
        transitionAnimStyle={footerGradientOpacityTransition}
        dimensions={{
          bottomMargin,
          extraBottomMargin: 0,
          gradientAreaHeight,
          spaceBetweenActions,
          safeBackgroundHeight: bottomMargin
        }}
      />
    </>
  );
};

export default BonusInformationComponent;