diff --git a/src/CONST.ts b/src/CONST.ts index 4ed6780ab4cd..ac4b9562672d 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -512,6 +512,7 @@ const CONST = { MIN_DATE: '0001-01-01', ORDINAL_DAY_OF_MONTH: 'do', MONTH_DAY_YEAR_ORDINAL_FORMAT: 'MMMM do, yyyy', + SECONDS_PER_DAY: 24 * 60 * 60, }, SMS: { DOMAIN: '@expensify.sms', @@ -897,6 +898,7 @@ const CONST = { get DIRECT_REIMBURSEMENT_CURRENCIES() { return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.EUR]; }, + TRIAL_DURATION_DAYS: 8, EXAMPLE_PHONE_NUMBER: '+15005550006', CONCIERGE_CHAT_NAME: 'Concierge', CLOUDFRONT_URL, diff --git a/src/languages/en.ts b/src/languages/en.ts index d010b2d78d7d..7c3bd33c8b2a 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -60,6 +60,8 @@ import type { DeleteActionParams, DeleteConfirmationParams, DidSplitAmountMessageParams, + EarlyDiscountSubtitleParams, + EarlyDiscountTitleParams, EditActionParams, EditDestinationSubtitleParams, ElectronicFundsParams, @@ -5363,6 +5365,19 @@ const translations = { title: 'Your free trial has ended', subtitle: 'Add a payment card to continue using all of your favorite features.', }, + earlyDiscount: { + claimOffer: 'Claim Offer', + noThanks: 'No thanks', + subscriptionPageTitle: { + phrase1: ({discountType}: EarlyDiscountTitleParams) => `${discountType}% off your first year!`, + phrase2: `Just add a payment card and start an annual subscription!`, + }, + onboardingChatTitle: { + phrase1: 'Limited time offer:', + phrase2: ({discountType}: EarlyDiscountTitleParams) => `${discountType}% off your first year!`, + }, + subtitle: ({days, hours, minutes, seconds}: EarlyDiscountSubtitleParams) => `Claim within ${days > 0 ? `${days}d : ` : ''}${hours}h : ${minutes}m : ${seconds}s`, + }, }, cardSection: { title: 'Payment', diff --git a/src/languages/es.ts b/src/languages/es.ts index 594fb1ea00ae..140891c7a4fa 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -59,6 +59,8 @@ import type { DeleteActionParams, DeleteConfirmationParams, DidSplitAmountMessageParams, + EarlyDiscountSubtitleParams, + EarlyDiscountTitleParams, EditActionParams, EditDestinationSubtitleParams, ElectronicFundsParams, @@ -5880,6 +5882,19 @@ const translations = { title: 'Tu prueba gratuita ha terminado', subtitle: 'Añade una tarjeta de pago para seguir utilizando tus funciones favoritas.', }, + earlyDiscount: { + claimOffer: 'Solicitar oferta', + noThanks: 'No, gracias', + subscriptionPageTitle: { + phrase1: ({discountType}: EarlyDiscountTitleParams) => `¡${discountType}% de descuento en tu primer año!`, + phrase2: `¡Solo añade una tarjeta de pago y comienza una suscripción anual!`, + }, + onboardingChatTitle: { + phrase1: 'Oferta por tiempo limitado:', + phrase2: ({discountType}: EarlyDiscountTitleParams) => `¡${discountType}% de descuento en tu primer año!`, + }, + subtitle: ({days, hours, minutes, seconds}: EarlyDiscountSubtitleParams) => `Solicítala en ${days > 0 ? `${days}d : ` : ''}${hours}h : ${minutes}m : ${seconds}s`, + }, }, cardSection: { title: 'Pago', diff --git a/src/languages/params.ts b/src/languages/params.ts index 526dcc5248bd..9d28f198b704 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -419,6 +419,10 @@ type BillingBannerCardOnDisputeParams = {amountOwed: string; cardEnding: string} type TrialStartedTitleParams = {numOfDays: number}; +type EarlyDiscountTitleParams = {discountType: number}; + +type EarlyDiscountSubtitleParams = {days: number; hours: number; minutes: number; seconds: number}; + type CardNextPaymentParams = {nextPaymentDate: string}; type CardEndingParams = {cardNumber: string}; @@ -644,6 +648,8 @@ export type { BillingBannerCardExpiredParams, BillingBannerCardOnDisputeParams, TrialStartedTitleParams, + EarlyDiscountTitleParams, + EarlyDiscountSubtitleParams, RemoveMemberPromptParams, StatementTitleParams, RenamedWorkspaceNameActionParams, diff --git a/src/libs/API/parameters/AddPaymentCardParams.ts b/src/libs/API/parameters/AddPaymentCardParams.ts index 3a59c678ac4f..7f4e35bff0f5 100644 --- a/src/libs/API/parameters/AddPaymentCardParams.ts +++ b/src/libs/API/parameters/AddPaymentCardParams.ts @@ -10,5 +10,6 @@ type AddPaymentCardParams = { addressZip: string; currency: ValueOf; isP2PDebitCard: boolean; + shouldClaimEarlyDiscountOffer?: boolean; }; export default AddPaymentCardParams; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 8aa73c8b89d6..dbe768199dde 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -702,6 +702,12 @@ let isAnonymousUser = false; // Example case: when we need to get a report name of a thread which is dependent on a report action message. const parsedReportActionMessageCache: Record = {}; +let conciergeChatReportID: string | undefined; +Onyx.connect({ + key: ONYXKEYS.CONCIERGE_REPORT_ID, + callback: (value) => (conciergeChatReportID = value), +}); + const defaultAvatarBuildingIconTestID = 'SvgDefaultAvatarBuilding Icon'; Onyx.connect({ key: ONYXKEYS.SESSION, @@ -1456,7 +1462,7 @@ function isConciergeChatReport(report: OnyxInputOrEntry): boolean { return false; } - return participantAccountIDs.has(CONCIERGE_ACCOUNT_ID_STRING); + return participantAccountIDs.has(CONCIERGE_ACCOUNT_ID_STRING) || report?.reportID === conciergeChatReportID; } function findSelfDMReportID(): string | undefined { diff --git a/src/libs/SubscriptionUtils.ts b/src/libs/SubscriptionUtils.ts index 87f750957abc..b4ecbf5dd09e 100644 --- a/src/libs/SubscriptionUtils.ts +++ b/src/libs/SubscriptionUtils.ts @@ -1,11 +1,13 @@ import {differenceInSeconds, fromUnixTime, isAfter, isBefore} from 'date-fns'; +import {fromZonedTime} from 'date-fns-tz'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {BillingGraceEndPeriod, BillingStatus, Fund, FundList, Policy, StripeCustomerID} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {translateLocal} from './Localize'; -import * as PolicyUtils from './PolicyUtils'; +import {getOwnedPaidPolicies, isPolicyOwner} from './PolicyUtils'; const PAYMENT_STATUS = { POLICY_OWNER_WITH_AMOUNT_OWED: 'policy_owner_with_amount_owed', @@ -22,11 +24,19 @@ const PAYMENT_STATUS = { GENERIC_API_ERROR: 'generic_api_error', } as const; +type DiscountInfo = { + days: number; + hours: number; + minutes: number; + seconds: number; + discountType: number; +}; + let currentUserAccountID = -1; Onyx.connect({ key: ONYXKEYS.SESSION, callback: (value) => { - currentUserAccountID = value?.accountID ?? -1; + currentUserAccountID = value?.accountID ?? CONST.DEFAULT_NUMBER_ID; }, }); @@ -234,6 +244,53 @@ function hasCardExpiringSoon(): boolean { return isExpiringThisMonth || isExpiringNextMonth; } +function shouldShowDiscountBanner(): boolean { + if (!isUserOnFreeTrial()) { + return false; + } + + if (doesUserHavePaymentCardAdded()) { + return false; + } + + const dateNow = Math.floor(Date.now() / 1000); + const firstDayTimestamp = fromZonedTime(`${firstDayFreeTrial}`, 'UTC').getTime() / 1000; + const lastDayTimestamp = fromZonedTime(`${lastDayFreeTrial}`, 'UTC').getTime() / 1000; + if (dateNow > lastDayTimestamp) { + return false; + } + + return dateNow <= firstDayTimestamp + CONST.TRIAL_DURATION_DAYS * CONST.DATE.SECONDS_PER_DAY; +} + +function getEarlyDiscountInfo(): DiscountInfo | null { + if (!firstDayFreeTrial) { + return null; + } + const dateNow = Math.floor(Date.now() / 1000); + const firstDayTimestamp = fromZonedTime(`${firstDayFreeTrial}`, 'UTC').getTime() / 1000; + + let timeLeftInSeconds; + const timeLeft24 = CONST.DATE.SECONDS_PER_DAY - (dateNow - firstDayTimestamp); + if (timeLeft24 > 0) { + timeLeftInSeconds = timeLeft24; + } else { + timeLeftInSeconds = firstDayTimestamp + CONST.TRIAL_DURATION_DAYS * CONST.DATE.SECONDS_PER_DAY - dateNow; + } + + if (timeLeftInSeconds <= 0) { + return null; + } + + return { + days: Math.floor(timeLeftInSeconds / CONST.DATE.SECONDS_PER_DAY), + hours: Math.floor((timeLeftInSeconds % CONST.DATE.SECONDS_PER_DAY) / 3600), + minutes: Math.floor((timeLeftInSeconds % 3600) / 60), + seconds: Math.floor(timeLeftInSeconds % 60), + discountType: timeLeft24 > 0 ? 50 : 25, + }; +} + /** * @returns Whether there is a retry billing error. */ @@ -385,7 +442,7 @@ function calculateRemainingFreeTrialDays(): number { * @returns The free trial badge text . */ function getFreeTrialText(policies: OnyxCollection | null): string | undefined { - const ownedPaidPolicies = PolicyUtils.getOwnedPaidPolicies(policies, currentUserAccountID); + const ownedPaidPolicies = getOwnedPaidPolicies(policies, currentUserAccountID); if (isEmptyObject(ownedPaidPolicies)) { return undefined; } @@ -456,7 +513,7 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { // Extracts the owner account ID from the collection member key. const ownerAccountID = Number(entryKey.slice(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END.length)); - if (PolicyUtils.isPolicyOwner(policy, ownerAccountID)) { + if (isPolicyOwner(policy, ownerAccountID)) { return true; } } @@ -465,7 +522,7 @@ function shouldRestrictUserBillableActions(policyID: string): boolean { // If it reached here it means that the user is actually the workspace's owner. // We should restrict the workspace's owner actions if it's past its grace period end date and it's owing some amount. if ( - PolicyUtils.isPolicyOwner(policy, currentUserAccountID) && + isPolicyOwner(policy, currentUserAccountID) && ownerBillingGraceEndPeriod && amountOwed !== undefined && amountOwed > 0 && @@ -494,4 +551,6 @@ export { PAYMENT_STATUS, shouldRestrictUserBillableActions, shouldShowPreTrialBillingBanner, + shouldShowDiscountBanner, + getEarlyDiscountInfo, }; diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index e645267b32fd..89d8ef741f14 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -235,6 +235,7 @@ function addSubscriptionPaymentCard( addressZip, currency, isP2PDebitCard: false, + shouldClaimEarlyDiscountOffer: true, }; const optimisticData: OnyxUpdate[] = [ diff --git a/src/pages/home/HeaderView.tsx b/src/pages/home/HeaderView.tsx index 02dffe751beb..05646805e0b0 100644 --- a/src/pages/home/HeaderView.tsx +++ b/src/pages/home/HeaderView.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {memo, useEffect} from 'react'; +import React, {memo, useEffect, useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; @@ -57,6 +57,8 @@ import { shouldDisableDetailPage as shouldDisableDetailPageReportUtils, shouldReportShowSubscript, } from '@libs/ReportUtils'; +import {shouldShowDiscountBanner} from '@libs/SubscriptionUtils'; +import EarlyDiscountBanner from '@pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner'; import FreeTrial from '@pages/settings/Subscription/FreeTrial'; import {joinRoom} from '@userActions/Report'; import {checkIfActionIsAllowed} from '@userActions/Session'; @@ -102,6 +104,8 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, const [parentReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${getNonEmptyStringOnyxID(report?.parentReportID) ?? getNonEmptyStringOnyxID(report?.reportID)}`); const policy = usePolicy(report?.policyID); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); + const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const {translate} = useLocalize(); @@ -176,6 +180,12 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, ); }; + // If the onboarding report is directly loaded, shouldShowDiscountBanner can return wrong value as it is not + // linked to the react lifecycle directly. Wait for trial dates to load, before calculating. + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + const shouldShowDiscount = useMemo(() => shouldShowDiscountBanner(), [firstDayFreeTrial, lastDayFreeTrial]); + const shouldShowSubscript = shouldReportShowSubscript(report); const defaultSubscriptSize = isExpenseRequest(report) ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; const icons = getIcons(reportHeaderData, personalDetails, null, '', -1, policy, invoiceReceiverPolicy); @@ -210,166 +220,169 @@ function HeaderView({report, parentReportAction, onNavigationMenuButtonClicked, }; return ( - - - {isLoading ? ( - - ) : ( - - {shouldUseNarrowLayout && ( - - + + + {isLoading ? ( + + ) : ( + + {shouldUseNarrowLayout && ( + - - - - - - )} - - navigateToDetailsPage(report, Navigation.getReportRHPActiveRoute())} - style={[styles.flexRow, styles.alignItemsCenter, styles.flex1]} - disabled={shouldDisableDetailPage} - accessibilityLabel={title} - role={CONST.ROLE.BUTTON} - > - {shouldShowSubscript ? ( - - ) : ( - - - - )} - + + + + + + )} + + navigateToDetailsPage(report, Navigation.getReportRHPActiveRoute())} + style={[styles.flexRow, styles.alignItemsCenter, styles.flex1]} + disabled={shouldDisableDetailPage} + accessibilityLabel={title} + role={CONST.ROLE.BUTTON} > - - - - {!isEmptyObject(parentNavigationSubtitleData) && ( - + ) : ( + + + )} - {shouldShowSubtitle() && ( - - {subtitle} - - )} - {isChatRoom && !!reportDescription && isEmptyObject(parentNavigationSubtitleData) && ( - - + + - {reportDescription} - - - )} - {isPolicyExpenseChat && !!policyDescription && isEmptyObject(parentNavigationSubtitleData) && ( - + textStyles={[styles.headerText, styles.pre]} + shouldUseFullTitle={isChatRoom || isPolicyExpenseChat || isChatThread || isTaskReport || shouldUseGroupTitle} + renderAdditionalText={renderAdditionalText} + /> + + {!isEmptyObject(parentNavigationSubtitleData) && ( + + )} + {shouldShowSubtitle() && ( - {policyDescription} + {subtitle} + )} + {isChatRoom && !!reportDescription && isEmptyObject(parentNavigationSubtitleData) && ( + + + {reportDescription} + + + )} + {isPolicyExpenseChat && !!policyDescription && isEmptyObject(parentNavigationSubtitleData) && ( + + + {policyDescription} + + + )} + + {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( + + )} - - {brickRoadIndicator === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR && ( - - + + {shouldShowGuideBooking && !shouldUseNarrowLayout && guideBookingButton} + {!shouldUseNarrowLayout && !shouldShowDiscount && isChatUsedForOnboarding && ( + - - )} - - - {shouldShowGuideBooking && !shouldUseNarrowLayout && guideBookingButton} - {!shouldUseNarrowLayout && isChatUsedForOnboarding && ( - - )} - {!shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && } - {!isParentReportLoading && canJoin && !shouldUseNarrowLayout && joinButton} + )} + {!shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && } + {!isParentReportLoading && canJoin && !shouldUseNarrowLayout && joinButton} + + {shouldDisplaySearchRouter && } - {shouldDisplaySearchRouter && } + { + setIsDeleteTaskConfirmModalVisible(false); + deleteTask(report); + }} + onCancel={() => setIsDeleteTaskConfirmModalVisible(false)} + title={translate('task.deleteTask')} + prompt={translate('task.deleteConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + shouldEnableNewFocusManagement + /> - { - setIsDeleteTaskConfirmModalVisible(false); - deleteTask(report); - }} - onCancel={() => setIsDeleteTaskConfirmModalVisible(false)} - title={translate('task.deleteTask')} - prompt={translate('task.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - shouldEnableNewFocusManagement + )} + + {!isParentReportLoading && !isLoading && canJoin && shouldUseNarrowLayout && {joinButton}} + + {!isLoading && shouldShowGuideBooking && shouldUseNarrowLayout && {guideBookingButton}} + {!isLoading && !shouldShowDiscount && isChatUsedForOnboarding && shouldUseNarrowLayout && ( + + )} + + {!!report && shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && ( + + + + )} - {!isParentReportLoading && !isLoading && canJoin && shouldUseNarrowLayout && {joinButton}} - - {!isLoading && shouldShowGuideBooking && shouldUseNarrowLayout && {guideBookingButton}} - {!isLoading && isChatUsedForOnboarding && shouldUseNarrowLayout && ( - - )} - - {!!report && shouldUseNarrowLayout && isOpenTaskReport(report, parentReportAction) && ( - - - - - - )} - + {shouldShowDiscount && isChatUsedForOnboarding && } + ); } diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx index bbb06dac4549..76a754dfc652 100644 --- a/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/BillingBanner.tsx @@ -42,9 +42,24 @@ type BillingBannerProps = { /** Accessibility label for the right icon. */ rightIconAccessibilityLabel?: string; + + /** A component to be rendered on the right side of the banner. */ + rightComponent?: React.ReactNode; }; -function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleStyle, subtitleStyle, rightIcon, onRightIconPress, rightIconAccessibilityLabel}: BillingBannerProps) { +function BillingBanner({ + title, + subtitle, + icon, + brickRoadIndicator, + style, + titleStyle, + subtitleStyle, + rightIcon, + onRightIconPress, + rightIconAccessibilityLabel, + rightComponent, +}: BillingBannerProps) { const styles = useThemeStyles(); const theme = useTheme(); @@ -81,7 +96,7 @@ function BillingBanner({title, subtitle, icon, brickRoadIndicator, style, titleS }, [brickRoadIndicator, onRightIconPress, rightIcon, rightIconAccessibilityLabel, styles.touchableButtonImage, theme.danger, theme.icon, theme.success]); return ( - + {title} : title} {typeof subtitle === 'string' ? {subtitle} : subtitle} + {!!rightComponent && rightComponent} {rightIconComponent} ); diff --git a/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx new file mode 100644 index 000000000000..71b6c2641263 --- /dev/null +++ b/src/pages/settings/Subscription/CardSection/BillingBanner/EarlyDiscountBanner.tsx @@ -0,0 +1,105 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import * as Illustrations from '@components/Icon/Illustrations'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import {getEarlyDiscountInfo} from '@libs/SubscriptionUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BillingBanner from './BillingBanner'; + +type EarlyDiscountBannerProps = { + /** Whether the banner is being displayed on the subscription page. */ + isSubscriptionPage: boolean; +}; + +function EarlyDiscountBanner({isSubscriptionPage}: EarlyDiscountBannerProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + + const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); + const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL); + + const initialDiscountInfo = getEarlyDiscountInfo(); + const [discountInfo, setDiscountInfo] = useState(initialDiscountInfo); + const [isDismissed, setIsDismissed] = useState(false); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + useEffect(() => { + const intervalID = setInterval(() => { + setDiscountInfo(getEarlyDiscountInfo()); + }, 1000); + + return () => clearInterval(intervalID); + }, [firstDayFreeTrial]); + + const rightComponent = useMemo(() => { + const smallScreenStyle = shouldUseNarrowLayout ? [styles.flex0, styles.flexBasis100, styles.justifyContentCenter] : []; + return ( + +