feat: use discount info endpoint for streak discount information (#1763)

* feat: use discount info endpoint for streak discount information

* feat: pass course run key to discount code info call

* feat: move changes behind a flag

* fix: use async IIFE inside useEffect

* fix: fix line length

* fix: remove default value in dev

* fix: improve coverage by adding conditional test based on env value

* refactor: move logic inside function

* refactor: move functions to utils

* fix: ignore merge config
This commit is contained in:
Nawfal Ahmed
2025-07-31 23:53:18 +05:00
committed by GitHub
parent 4c8aa7c80c
commit 9cbc2276d6
7 changed files with 117 additions and 41 deletions

1
.env
View File

@@ -12,6 +12,7 @@ CREDIT_HELP_LINK_URL=''
CSRF_TOKEN_API_PATH=''
DISCOVERY_API_BASE_URL=''
DISCUSSIONS_MFE_BASE_URL=''
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL=''
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

View File

@@ -12,6 +12,7 @@ CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-co
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

View File

@@ -12,6 +12,7 @@ CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-co
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
DISCOVERY_API_BASE_URL='http://localhost:18381'
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
DISCOUNT_CODE_INFO_URL=''
ECOMMERCE_BASE_URL='http://localhost:18130'
ENABLE_JUMPNAV='true'
ENABLE_NOTICES=''

View File

@@ -166,11 +166,13 @@ subscribe(APP_INIT_ERROR, (error) => {
initialize({
handlers: {
config: () => {
/* istanbul ignore next */
mergeConfig({
CONTACT_URL: process.env.CONTACT_URL || null,
CREDENTIALS_BASE_URL: process.env.CREDENTIALS_BASE_URL || null,
CREDIT_HELP_LINK_URL: process.env.CREDIT_HELP_LINK_URL || null,
DISCUSSIONS_MFE_BASE_URL: process.env.DISCUSSIONS_MFE_BASE_URL || null,
DISCOUNT_CODE_INFO_URL: process.env.DISCOUNT_CODE_INFO_URL || null,
ENTERPRISE_LEARNER_PORTAL_HOSTNAME: process.env.ENTERPRISE_LEARNER_PORTAL_HOSTNAME || null,
ENTERPRISE_LEARNER_PORTAL_URL: process.env.ENTERPRISE_LEARNER_PORTAL_URL || null,
ENABLE_JUMPNAV: process.env.ENABLE_JUMPNAV || null,

View File

@@ -1,9 +1,8 @@
/* eslint-disable react/prop-types */
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Lightbulb, MoneyFilled } from '@openedx/paragon/icons';
import {
@@ -16,7 +15,12 @@ import { useModel } from '../../generic/model-store';
import StreakMobileImage from './assets/Streak_mobile.png';
import StreakDesktopImage from './assets/Streak_desktop.png';
import messages from './messages';
import { recordModalClosing, recordStreakCelebration } from './utils';
import {
calculateVoucherDiscountPercentage,
getDiscountCodePercentage,
recordModalClosing,
recordStreakCelebration,
} from './utils';
function getRandomFactoid(intl, streakLength) {
const boldedSectionA = intl.formatMessage(messages.streakFactoidABoldedSection);
@@ -42,13 +46,6 @@ function getRandomFactoid(intl, streakLength) {
return factoids[Math.floor(Math.random() * (factoids.length))];
}
async function calculateVoucherDiscount(voucher, sku, username) {
const urlBase = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/baskets/calculate`;
const url = `${urlBase}/?code=${voucher}&sku=${sku}&username=${username}`;
return getAuthenticatedHttpClient().get(url)
.then(res => camelCaseObject(res));
}
const CloseText = ({ intl }) => (
<span>
{intl.formatMessage(messages.streakButton)}
@@ -83,34 +80,38 @@ const StreakModal = ({
// Ask ecommerce to calculate discount savings
useEffect(() => {
if (streakDiscountCouponEnabled && verifiedMode && getConfig().ECOMMERCE_BASE_URL) {
calculateVoucherDiscount(discountCode, verifiedMode.sku, username)
.then(
(result) => {
const { totalInclTax, totalInclTaxExclDiscounts } = result.data;
if (totalInclTaxExclDiscounts && totalInclTax !== totalInclTaxExclDiscounts) {
// Just store the percent (rather than using these values directly), because ecommerce doesn't give us
// the currency symbol to use, so we want to use the symbol that LMS gives us. And I don't want to assume
// ecommerce's currency is the same as the LMS. So we'll keep using the values in verifiedMode, just
// multiplied by the calculated percentage.
setDiscountPercent(1 - totalInclTax / totalInclTaxExclDiscounts);
sendTrackEvent('edx.bi.course.streak_discount_enabled', {
course_id: courseId,
sku: verifiedMode.sku,
});
} else {
setDiscountPercent(0);
}
},
() => {
// ignore any errors - we just won't show the discount to the user then
setDiscountPercent(0);
},
);
} else {
setDiscountPercent(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
(async () => {
let streakDiscountPercentage = 0;
try {
if (streakDiscountCouponEnabled && verifiedMode) {
// If the discount service is available, use it to get the discount percentage
if (getConfig().DISCOUNT_CODE_INFO_URL) {
streakDiscountPercentage = await getDiscountCodePercentage(
discountCode,
courseId,
);
// If the discount service is not available, fall back to ecommerce to calculate the discount percentage
} else if (getConfig().ECOMMERCE_BASE_URL) {
streakDiscountPercentage = await calculateVoucherDiscountPercentage(
discountCode,
verifiedMode.sku,
username,
);
}
}
} catch {
// ignore any errors - we just won't show the discount to the user then
} finally {
if (streakDiscountPercentage) {
sendTrackEvent('edx.bi.course.streak_discount_enabled', {
course_id: courseId,
sku: verifiedMode.sku,
});
}
setDiscountPercent(streakDiscountPercentage);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [streakDiscountCouponEnabled, username, verifiedMode]);
if (!isStreakCelebrationOpen) {

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Factory } from 'rosie';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { camelCaseObject, getConfig, mergeConfig } from '@edx/frontend-platform';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { breakpoints } from '@openedx/paragon';
@@ -34,6 +34,19 @@ describe('Loaded Tab Page', () => {
});
}
function setDiscountViaDiscountCodeInfo(percent) {
const discountURLParams = new URLSearchParams();
discountURLParams.append('code', 'ZGY11119949');
discountURLParams.append('course_run_key', courseMetadata.id);
const discountURL = `${getConfig().DISCOUNT_CODE_INFO_URL}?${discountURLParams.toString()}`;
mockData.streakDiscountCouponEnabled = true;
axiosMock.onGet(discountURL).reply(200, {
isApplicable: true,
discountPercentage: percent / 100,
});
}
function setDiscountError() {
mockData.streakDiscountCouponEnabled = true;
axiosMock.onGet(calculateUrl).reply(500);
@@ -105,4 +118,22 @@ describe('Loaded Tab Page', () => {
sku: mockData.verifiedMode.sku,
});
});
it('shows discount version of streak celebration modal when discount available and info fetched using DISCOUNT_CODE_INFO_URL', async () => {
mergeConfig({ DISCOUNT_CODE_INFO_URL: 'http://localhost:8140/lms/discount-code-info/' });
global.innerWidth = breakpoints.extraSmall.maxWidth;
setDiscountViaDiscountCodeInfo(14);
await renderModal();
const endDateText = `Ends ${new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toLocaleDateString({ timeZone: 'UTC' })}.`;
expect(screen.getByText('Youve unlocked a 14% off discount when you upgrade this course for a limited time only.', { exact: false })).toBeInTheDocument();
expect(screen.getByText(endDateText, { exact: false })).toBeInTheDocument();
expect(screen.getByText('Continue with course')).toBeInTheDocument();
expect(screen.queryByText('Keep it up')).not.toBeInTheDocument();
expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.course.streak_discount_enabled', {
course_id: mockData.courseId,
sku: mockData.verifiedMode.sku,
});
});
});

View File

@@ -1,5 +1,9 @@
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import {
getAuthenticatedHttpClient,
getAuthenticatedUser,
} from '@edx/frontend-platform/auth';
import { updateModel } from '../../generic/model-store';
@@ -24,4 +28,39 @@ function recordModalClosing(celebrations, org, courseId, dispatch) {
}));
}
export { recordStreakCelebration, recordModalClosing };
async function calculateVoucherDiscountPercentage(voucher, sku, username) {
const urlBase = `${getConfig().ECOMMERCE_BASE_URL}/api/v2/baskets/calculate`;
const url = `${urlBase}/?code=${voucher}&sku=${sku}&username=${username}`;
const result = await getAuthenticatedHttpClient().get(url);
const { totalInclTax, totalInclTaxExclDiscounts } = camelCaseObject(result).data;
if (totalInclTaxExclDiscounts && totalInclTax !== totalInclTaxExclDiscounts) {
// Just store the percent (rather than using these values directly), because ecommerce doesn't give us
// the currency symbol to use, so we want to use the symbol that LMS gives us. And I don't want to assume
// ecommerce's currency is the same as the LMS. So we'll keep using the values in verifiedMode, just
// multiplied by the calculated percentage.
return 1 - totalInclTax / totalInclTaxExclDiscounts;
}
return 0;
}
async function getDiscountCodePercentage(code, courseId) {
const params = new URLSearchParams();
params.append('code', code);
params.append('course_run_key', courseId);
const url = `${getConfig().DISCOUNT_CODE_INFO_URL}?${params.toString()}`;
const result = await getAuthenticatedHttpClient().get(url);
const { isApplicable, discountPercentage } = camelCaseObject(result).data;
return isApplicable ? +discountPercentage : 0;
}
export {
calculateVoucherDiscountPercentage,
getDiscountCodePercentage,
recordModalClosing,
recordStreakCelebration,
};