Skip to content

[HOLD for payment 2025-02-05] [Early Adoption Discount] - Implement the early discount countdown banner #54817

@youssef-lr

Description

@youssef-lr

Part of the Early Adoption Discount project

Main issue: https://github.com/Expensify/Expensify/issues/457641
Doc section: https://docs.google.com/document/d/1C8EIyxvoZGhYJOLA8J1QO2EqUkAX0LBFmkVSJaUeGF8/edit?tab=t.0#bookmark=id.cr9hpaweltm6
Project: https://github.com/orgs/Expensify/projects/177/views/7

Feature Description

In order to display the banner, we need to check first that the free trial is ongoing. If yes, we can use the same logic used in the backend to check if there is still time to display the banner. We’ll code a utility function in SubscriptionUtils that does the following (note that this only controls whether or not we show the banner, it doesn’t control which discount is shown):

Utility function to control the display of the banner

SubscriptionUtils

function shouldShowDiscountBanner(): boolean {
    if (!isUserOnFreeTrial()) {
        return false;
    }

    if (doesUserHavePaymentCardAdded()) {
        return false;
    }

    // We'll use comparisons in seconds in this project
    const dateNow = Date.now() / 1000;

    // As well as UTC comparisons
    const firstDayTimestamp = new Date(`${firstDayFreeTrial} UTC`).getTime() / 1000;
    const lastDayTimestamp = new Date(`${lastDayFreeTrial} UTC`).getTime() / 1000;
    if (dateNow > lastDayTimestamp) {
        return false;
    }

    return dateNow <= firstDayTimestamp + 8 * CONST.DATE.SECONDS_PER_DAY * 1000;
}

Utility function to calculate the time remaining

SubscriptionUtils

function getEarlyDiscountInfo(firstDayFreeTrial: string) {
    const dateNow = Date.now() / 1000;
    const firstDayTimestamp = new Date(`${firstDayFreeTrial} UTC`).getTime() / 1000;

    let timeLeftInSeconds;
    const timeLeft24 = CONST.DATE.SECONDS_PER_DAY - (dateNow - firstDayTimestamp);
    if (timeLeft24 > 0) {
        timeLeftInSeconds = timeLeft24;
    } else {
        timeLeftInSeconds = firstDayTimestamp + 8 * 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: timeLeftInSeconds % 60,
        discountType: timeLeft24 > 0 ? 50 : 25,
    };
}

New countdown component

Now that we have everything we need to show a countdown, let’s create a new component that will use the functions above. We’ll simply create a new wrapper around BillingBanner which already has the same styling we need. We’ll also need to modify BilingBanner to take a right component which should host the buttons.

function EarlyDiscountBanner({isSubscriptionPage}) {
    const {translate} = useLocalize();
    const styles = useThemeStyles();

    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);

    useEffect(() => {
        const intervalID = setInterval(() => {
            setDiscountInfo(getEarlyDiscountInfo());
        }, 1000);

        return () => clearInterval(intervalID);
    }, [firstDayFreeTrial]);

    const title = isSubscriptionPage ? (
        <Text style={styles.textStrong}>
            {discountInfo?.discountType}% off your first year!&nbsp;
            <Text>Just add payment card and start an annual subscription!</Text>
        </Text>
    ) : (
        <Text style={styles.textStrong}>
            Limited time offer:&nbsp;
            <Text>{discountInfo?.discountType}% off your first year!</Text>
        </Text>
    );

    const formatTimeRemaining = useCallback(() => {
        if (discountInfo?.days === 0) {
            return `Claim within ${discountInfo?.hours}h : ${discountInfo?.minutes}m : ${discountInfo?.seconds}s`;
        }
        return `Claim within ${discountInfo?.days}d : ${discountInfo?.hours}h : ${discountInfo?.minutes}m : ${discountInfo?.seconds}s`;
    }, [discountInfo]);

    const {shouldUseNarrowLayout} = useResponsiveLayout();
    const rightComponent = useMemo(() => {
        const smallScreenStyle = shouldUseNarrowLayout ? [styles.flex0, styles.flexBasis100, styles.flexRow, styles.justifyContentCenter] : [];
        return (
            <View style={[styles.flexRow, styles.gap2, smallScreenStyle]}>
                <Button
                    success
                    text="Claim offer"
                    onPress={() => Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION)}
                />
                // Only allow dismissing the 25% discount
                {discountInfo?.discountType === 25 && (
                    <Button
                        text="No thanks"
                        onPress={() => setIsDismissed(true)}
                    />
                )}
            </View>
        );
    }, [shouldUseNarrowLayout, styles.flex0, styles.flexRow, styles.flexBasis100, styles.gap2, styles.justifyContentCenter, discountInfo]);

    if (!firstDayFreeTrial || !lastDayFreeTrial || !discountInfo) {
        return null;
    }


    // Banner is not dismissible in the subscription page
    if (isDismissed && !isSubscriptionPage) {
        return null;
    }

    return (
        <BillingBanner
            title={title}
            subtitle={formatTimeRemaining()}
            subtitleStyle={[styles.mt1, styles.mutedNormalTextLabel]}
            icon={Illustrations.TreasureChest}
            rightComponent={!isSubscriptionPage && rightComponent}
        />
    );
}

And to display it, we can add the following to HeaderView:

function HeaderView({report, parentReportAction, reportID, onNavigationMenuButtonClicked, ...)
    return (
        <>
            <View
                style={[shouldShowBorderBottom && styles.borderBottom]}
                dataSet={{dragArea: true}}
            >
                ...
            </View>
            // The component will be displayed either in the #admins room or concierge.
            // Depnding on which chat was used for onboarding
            {shouldShowDiscountBanner() && ReportUtils.isChatUsedForOnboarding(report) && <EarlyDiscountBanner />}
        </>
    );
}

As well as add it to CardSection so we can display it in the Subscriptions page:

if (SubscriptionUtils.shouldShowDiscountBanner()) {
    BillingBanner = <EarlyDiscountBanner isSubscriptionPage />;
} else if (SubscriptionUtils.shouldShowPreTrialBillingBanner()) {
    BillingBanner = <PreTrialBillingBanner />;
} else if (SubscriptionUtils.isUserOnFreeTrial()) {
    BillingBanner = <TrialStartedBillingBanner />;
} else if (SubscriptionUtils.hasUserFreeTrialEnded()) {
    BillingBanner = <TrialEndedBillingBanner />;
}

Warning modal when choosing PPU

In the SubscriptionDetails, we’ll create a modal that is displayed whenever the user tries to choose PPU when the discount is applied. We’ll also display this modal if the user is already on a PPU plan and they attempt to claim the offer. The modal should explain that the discount is only applicable to yearly subscriptions.

Manual Test Steps

Claiming the first discount

  • Sign up as a new user
  • Create a workspace
  • Go to your onboarding chat
  • Verify you can see a countdown timer starting from 24h.
  • Verify the banner is not dismissable.
  • Verify click on “Claim offer” takes you to the subscription page.
  • Verify the subscription page shows the same timer.
  • Verify that after you add a card, a promoCode of 50% has been applied to your account. Verify

Claiming the second discount

  • Sign up as a new user
  • Create a workspace
  • Modify the NVP firstDayFreeTrial such that it's earlier than 24h. Run this in the console: Onyx.merge("nvp_private_firstDayFreeTrial", "2024-12-21 23:36:00");
  • Go to your onboarding chat
  • Verify you can see a countdown showing days, hours, minutes, and seconds of the time remaining, which should be max 7 days when it starts.
  • Verify the banner is dismissable.
  • Verify click on “Claim offer” takes you to the subscription page.
  • Verify the subscription page shows the same timer.

Period past 8 days

  • Sign up as a new user
  • Create a workspace
  • Modify the NVP firstDayFreeTrial such that it's later than 8 days. Run this in the console: Onyx.merge("nvp_private_firstDayFreeTrial", "2024-12-21 23:36:00");
  • Verify no banner is shown in the onboarding chat.
  • Verify no banner is shown in the subscription page.
  • Verify that in the subscription, we’re showing the usual free trial banner instead.

Automated Tests

The same tests as above coded implemented in unit tests.

Issue OwnerCurrent Issue Owner: @stephanieelliott

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributorNewFeatureSomething to build that is a new item.

Type

No type

Projects

Status

Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions