FE: display purchase summary in sidebar#168
FE: display purchase summary in sidebar#168rkumarsingh-sonata-crypto wants to merge 23 commits intomainfrom
Conversation
c5198db to
d88a2bc
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #168 +/- ##
==========================================
+ Coverage 88.08% 88.11% +0.03%
==========================================
Files 155 160 +5
Lines 2920 3013 +93
Branches 576 608 +32
==========================================
+ Hits 2572 2655 +83
- Misses 348 358 +10 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR updates the Purchase Summary sidebar UI to support an “Essentials” flow variant, changing header/subtitle behavior, pricing/total calculations, and the footer CTA.
Changes:
- Add Essentials-specific Purchase Summary rendering (different header/subtitle, hard-coded per-user price, conditional testimonial display, and an extra “Compare plans” card).
- Introduce an “Upgrade to Teams” footer CTA and update PurchaseSummaryCardButton to support an
isEssentialsoverride. - Adjust several tests to align with updated Purchase Summary rendering and to relax some mutation hook assertions.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/test/common.test.ts | Adjusts Jest system time setup to use a numeric timestamp. |
| src/components/StatefulButton/tests/StatefulProvisioningButton.test.tsx | Simplifies window mocking (removes location override). |
| src/components/PurchaseSummary/UpgradeToTeamsButton.tsx | Adds a new “Upgrade to Teams” button component. |
| src/components/PurchaseSummary/TotalAfterTrialRow.tsx | Changes the “Total after trial” right-side display when quantity is missing/0. |
| src/components/PurchaseSummary/tests/TotalAfterTrialRow.test.tsx | Updates assertions for the “Total after trial” row display. |
| src/components/PurchaseSummary/tests/PurchaseSummaryHeader.test.tsx | Updates header tests and adds i18n wrapper. |
| src/components/PurchaseSummary/tests/PurchaseSummary.test.tsx | Updates PurchaseSummary header expectation to match new subtitle format. |
| src/components/PurchaseSummary/PurchaseSummaryHeader.tsx | Adds isEssentials prop and changes subtitle/title rendering. |
| src/components/PurchaseSummary/PurchaseSummaryCardButton.tsx | Adds isEssentials prop and new upgrade button type mapping. |
| src/components/PurchaseSummary/PurchaseSummary.tsx | Implements Essentials-specific pricing/total logic and conditional UI. |
| src/components/PurchaseSummary/PricePerUserRow.tsx | Adds Essentials-specific label text. |
| src/components/PurchaseSummary/DueTodayRow.tsx | Adjusts bold styling by wrapping label/right contents. |
| src/components/app/data/hooks/tests/useRegisterMutation.test.tsx | Loosens onMutate assertion to objectContaining. |
| src/components/app/data/hooks/tests/useLoginMutation.test.tsx | Loosens onMutate assertion to objectContaining. |
| src/components/app/data/hooks/tests/useCreateCheckoutIntentMutation.test.tsx | Loosens onMutate assertion to objectContaining. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { usePurchaseSummaryPricing } from '@/components/app/data'; | ||
| import useTestimonials from '@/components/app/data/hooks/useTestimonials'; | ||
| import { isEssentialsFlow } from '@/components/app/routes/loaders/utils'; | ||
| import { DataStoreKey } from '@/constants/checkout'; | ||
| import { useCheckoutFormStore } from '@/hooks/index'; |
There was a problem hiding this comment.
isEssentialsFlow is imported from components/app/routes/loaders/utils, which pulls in heavy loader-only dependencies (e.g., zod resolvers/Stripe types). Importing that module into a render component can unnecessarily bloat the client bundle; consider moving isEssentialsFlow into a small, client-safe utility module (or duplicating the tiny sessionStorage check locally) and importing from there instead.
There was a problem hiding this comment.
Done in commit 2fcb92b. isEssentialsFlow has been moved to src/utils/common.ts (alongside the similarly structured isFeatureEnabled function). loaders/utils.ts now re-exports it from there for backward compatibility with loader files, and all UI components (PurchaseSummary, StatefulSubscribeButton, BillingDetailsPage, AccountDetailsPage) now import from @/utils/common directly.
| // For Essentials, hardcode price to $149 | ||
| const pricePerUser = isEssentials ? 149 : yearlyCostPerSubscriptionPerUser; | ||
| const totalPerYear = normalizedQuantity && normalizedQuantity > 0 && pricePerUser | ||
| ? normalizedQuantity * pricePerUser | ||
| : null; |
There was a problem hiding this comment.
pricePerUser/totalPerYear uses truthiness checks (pricePerUser in particular), which will incorrectly treat a valid value of 0 as absent and produce null totals. Also, hardcoding 149 here makes pricing easy to drift from backend/config and harder to change. Prefer explicit null/undefined checks (e.g., pricePerUser != null) and source the Essentials price from a constant/config or the pricing context so the UI stays in sync.
There was a problem hiding this comment.
@copilot apply changes based on this feedback
| <DisplayPrice value={totalPerYear ?? 0} /> USD | ||
| </span> | ||
| ) | ||
| : ('-')}/yr | ||
| : '-' | ||
| }/yr |
There was a problem hiding this comment.
When quantity is missing/0 this currently renders just "/yr" (empty string before it). That makes the value column ambiguous and also contradicts the test name that says "shows dash". Consider rendering a clear placeholder such as - (resulting in -/yr) to match the pattern used in other rows (e.g., PricePerUserRow and LicensesRow) and update the test expectation accordingly.
There was a problem hiding this comment.
@copilot apply changes based on this feedback
There was a problem hiding this comment.
@copilot apply changes based on this feedback
| const PurchaseSummaryHeaderComponent: React.FC<PurchaseSummaryHeaderProps> = ({ companyName, isEssentials }) => { | ||
| let subtitle: string | undefined; | ||
| if (isEssentials) { | ||
| subtitle = companyName || undefined; | ||
| } else { | ||
| subtitle = companyName | ||
| ? `${companyName} • Team subscription, price per user, paid yearly.` | ||
| : 'Team subscription, price per user, paid yearly.'; |
There was a problem hiding this comment.
The plan-description text is duplicated here as a hardcoded English string and is slightly inconsistent with the label in PricePerUserRow (capitalization/punctuation). To avoid UI drift and ease localization, consider sourcing this from a shared i18n message/constant and composing the subtitle using that single source of truth.
| const PurchaseSummaryHeaderComponent: React.FC<PurchaseSummaryHeaderProps> = ({ companyName, isEssentials }) => { | |
| let subtitle: string | undefined; | |
| if (isEssentials) { | |
| subtitle = companyName || undefined; | |
| } else { | |
| subtitle = companyName | |
| ? `${companyName} • Team subscription, price per user, paid yearly.` | |
| : 'Team subscription, price per user, paid yearly.'; | |
| const TEAM_SUBSCRIPTION_PLAN_DESCRIPTION = 'Team subscription, price per user, paid yearly.'; | |
| const PurchaseSummaryHeaderComponent: React.FC<PurchaseSummaryHeaderProps> = ({ companyName, isEssentials }) => { | |
| let subtitle: string | undefined; | |
| if (isEssentials) { | |
| subtitle = companyName || undefined; | |
| } else { | |
| subtitle = companyName | |
| ? `${companyName} • ${TEAM_SUBSCRIPTION_PLAN_DESCRIPTION}` | |
| : TEAM_SUBSCRIPTION_PLAN_DESCRIPTION; |
| const buttonType = useMemo( | ||
| (): ButtonType => ROUTE_BUTTON_MAP[location.pathname] ?? BUTTON_TYPES.NONE, | ||
| [location.pathname], | ||
| (): ButtonType => { | ||
| if (isEssentials) { | ||
| return BUTTON_TYPES.UPGRADE; | ||
| } |
There was a problem hiding this comment.
The new isEssentials branch introduces a new button type (UPGRADE) that isn't exercised by the existing PurchaseSummaryCardButton tests. Add a test that renders with isEssentials={true} and asserts the upgrade button is shown (and that route changes don’t affect it) to prevent regressions.
There was a problem hiding this comment.
@copilot apply changes based on this feedback
There was a problem hiding this comment.
@copilot apply changes based on this feedback
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // If we don't have the prerequisites for Stripe, render the page shell without Stripe Elements. | ||
| if (!checkoutSessionClientSecret || !PUBLISHABLE_STRIPE_API_KEY || !stripePromise) { | ||
| return children; |
There was a problem hiding this comment.
StripeProvider returns children when Stripe prerequisites (client secret / publishable key / stripePromise) are missing. In this app the only current usage wraps BillingDetailsPage, which renders Stripe components (AddressElement/PaymentElement) that require the Stripe context—rendering them without CheckoutProvider will likely throw at runtime if the key is misconfigured. Prefer keeping the previous return null (or rendering an explicit non-Stripe fallback UI) unless children are guaranteed not to use Stripe context.
| // If we don't have the prerequisites for Stripe, render the page shell without Stripe Elements. | |
| if (!checkoutSessionClientSecret || !PUBLISHABLE_STRIPE_API_KEY || !stripePromise) { | |
| return children; | |
| // If we don't have the prerequisites for Stripe, avoid rendering Stripe-dependent children | |
| // outside of CheckoutProvider. | |
| if (!checkoutSessionClientSecret || !PUBLISHABLE_STRIPE_API_KEY || !stripePromise) { | |
| return null; |
| renderWithI18n(<TotalAfterTrialRow quantity={quantity} totalPerYear={totalPerYear} />); | ||
| validateText(`Total after ${SUBSCRIPTION_TRIAL_LENGTH_DAYS}-day free trial`); | ||
| validateText('-/yr'); | ||
| validateText('/yr'); |
There was a problem hiding this comment.
This test now asserts an exact text node of "/yr", but TotalAfterTrialRow renders a dash placeholder and then appends /yr, producing "-/yr" as the combined text content. Update the expectation to match the rendered output (e.g., assert "-/yr", or use a non-exact match if you only care about the suffix).
| validateText('/yr'); | |
| validateText('-/yr'); |
ea897f7 to
13d6f52
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Agent-Logs-Url: https://github.com/edx/frontend-app-enterprise-checkout/sessions/cdf8568e-5024-4fd3-afd2-39b3c160ff72 Co-authored-by: rkumarsingh-sonata-crypto <265776069+rkumarsingh-sonata-crypto@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated 1 comment.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const PurchaseSummaryCardButton: React.FC<PurchaseSummaryCardButtonProps> = ({ isEssentials }) => { | ||
| const location = useLocation(); | ||
|
|
||
| const buttonType = useMemo( | ||
| (): ButtonType => ROUTE_BUTTON_MAP[location.pathname] ?? BUTTON_TYPES.NONE, | ||
| [location.pathname], | ||
| (): ButtonType => { | ||
| if (isEssentials) { | ||
| return BUTTON_TYPES.UPGRADE; | ||
| } | ||
| return ROUTE_BUTTON_MAP[location.pathname] ?? BUTTON_TYPES.NONE; | ||
| }, | ||
| [location.pathname, isEssentials], | ||
| ); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 35 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Returns true when the current session is operating in the Essentials flow. | ||
| * Reads from sessionStorage so it is safe to call from any client-side module. | ||
| */ | ||
| const isEssentialsFlow = (): boolean => sessionStorage.getItem('isEssentials') === 'true'; | ||
|
|
| interface PricePerUserRowProps { | ||
| pricePerUser?: number | null; | ||
| label?: string; | ||
| } | ||
|
|
||
| const PricePerUserRowComponent: React.FC<PricePerUserRowProps> = ({ pricePerUser }) => ( | ||
| const PricePerUserRowComponent: React.FC<PricePerUserRowProps> = ({ | ||
| pricePerUser, | ||
| label = 'Price per user, paid yearly', | ||
| }) => ( | ||
| <SummaryRow | ||
| label={( | ||
| <FormattedMessage | ||
| id="checkout.purchaseSummary.yearlyPricePerUser.text" | ||
| defaultMessage="Team Subscription, price per user, paid yearly" | ||
| description="Label for the team plan per user per year" | ||
| /> | ||
| )} | ||
| label={label} | ||
| right={pricePerUser == null ? '-' : <span><DisplayPrice value={pricePerUser} /> USD</span>} |
| import PurchaseSummaryCardButton from './PurchaseSummaryCardButton'; | ||
|
|
||
| const ESSENTIALS_PRICE_PER_USER = 149; | ||
|
|
||
| type AcademySelectionData = { |
| const CONSOLE_FILTERS = { | ||
| warn: [ | ||
| 'PubSub already loaded', | ||
| ], | ||
| error: [ | ||
| 'Support for defaultProps will be removed from function components', | ||
| 'Warning: An update to', | ||
| ], | ||
| }; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 24 changed files in this pull request and generated 7 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import { Card } from '@openedx/paragon'; | ||
|
|
||
| const COMPARE_PLANS_URL = 'https://business.edx.org/course-library-compare-plans/'; | ||
|
|
||
| const ComparePlansBox = () => ( | ||
| <Card className="bg-light border"> | ||
| <Card.Body className="text-center"> | ||
| <p className="mb-0 text-dark"> | ||
| <strong> | ||
| Not sure which plan is right for you?{' '} | ||
| <a | ||
| href={COMPARE_PLANS_URL} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| > | ||
| Compare plans | ||
| </a> |
| const normalizedQuantity = parseInt(quantity, 10) === 0 ? null : quantity; | ||
|
|
||
| const totalPerYear = normalizedQuantity && normalizedQuantity > 0 && yearlyCostPerSubscriptionPerUser |
| const { yearlyCostPerSubscriptionPerUser } = usePurchaseSummaryPricing(); | ||
|
|
||
| const normalizedQuantity = parseInt(quantity, 10) === 0 ? null : quantity; | ||
|
|
||
| const totalPerYear = normalizedQuantity && normalizedQuantity > 0 && yearlyCostPerSubscriptionPerUser | ||
| ? normalizedQuantity * yearlyCostPerSubscriptionPerUser |
| const UpgradeToTeamsButton: React.FC = () => ( | ||
| <Button | ||
| variant="outline-danger" | ||
| className="w-100 text-danger" | ||
| data-testid="upgrade-to-teams-button" | ||
| > | ||
| <Icon src={Lock} className="me-2" style={{ color: '#dc3545' }} /> | ||
| <FormattedMessage | ||
| id="components.PurchaseSummary.UpgradeToTeamsButton.upgradeToTeams" | ||
| defaultMessage="Upgrade to Teams" | ||
| description="Button text to upgrade to Teams plan" | ||
| /> | ||
| </Button> | ||
| ); | ||
|
|
| subtitle = headerName | ||
| ? `${headerName} • Team subscription, price per user, paid yearly.` | ||
| : 'Team subscription, price per user, paid yearly.'; | ||
| } | ||
|
|
||
| return ( | ||
| <Card.Header | ||
| title={<span className="font-weight-bold">Purchase summary</span>} | ||
| subtitle={subtitle} |
| import PurchaseSummaryBase from './PurchaseSummaryBase'; | ||
| import PurchaseSummaryCardButton from './PurchaseSummaryCardButton'; | ||
|
|
||
| const ESSENTIALS_PRICE_PER_USER = 149; |
| <PurchaseSummaryBase | ||
| headerName={academyName} | ||
| isEssentials | ||
| pricePerUser={ESSENTIALS_PRICE_PER_USER} | ||
| priceLabel="Essentials subscription, price per user, paid yearly." |
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Agent-Logs-Url: https://github.com/edx/frontend-app-enterprise-checkout/sessions/524d8165-8753-4d83-b292-a9aeb878c937 Co-authored-by: rkumarsingh-sonata-crypto <265776069+rkumarsingh-sonata-crypto@users.noreply.github.com>
Co-authored-by: rkumarsingh-sonata-crypto <265776069+rkumarsingh-sonata-crypto@users.noreply.github.com>
ed32b1d to
3064169
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated 10 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Returns true when the current session is operating in the Essentials flow. | ||
| * Reads from sessionStorage so it is safe to call from any client-side module. | ||
| */ | ||
| const isEssentialsFlow = (): boolean => sessionStorage.getItem('isEssentials') === 'true'; | ||
|
|
| const academyName = academySelectionData?.academyName | ||
| ?? planDetailsData?.academyName | ||
| ?? accountDetailsData?.companyName | ||
| ?? null; |
| subtitle = headerName | ||
| ? `${headerName} • Team subscription, price per user, paid yearly.` | ||
| : 'Team subscription, price per user, paid yearly.'; | ||
| } | ||
|
|
||
| return ( | ||
| <Card.Header | ||
| title={<span className="font-weight-bold">Purchase summary</span>} | ||
| subtitle={subtitle && <span className="purchase-summary-header-name">{subtitle}</span>} |
| <p className="mb-0 compare-plans-text"> | ||
| <strong> | ||
| Not sure which plan is right for you?{' '} | ||
| <a | ||
| href={COMPARE_PLANS_URL} | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="compare-plans-link" | ||
| > | ||
| Compare plans. | ||
| </a> | ||
| </strong> |
| const ESSENTIALS_PRICE_PER_USER = 149; | ||
|
|
| const UpgradeToTeamsButton: React.FC = () => ( | ||
| <Button | ||
| variant="outline-danger" | ||
| className="w-100 text-danger upgrade-to-teams-btn" | ||
| data-testid="upgrade-to-teams-button" | ||
| > | ||
| <Icon src={Lock} className="me-2" style={{ color: '#dc3545' }} /> |
| const CONSOLE_FILTERS = { | ||
| warn: [ | ||
| 'PubSub already loaded', | ||
| ], | ||
| error: [ | ||
| 'Support for defaultProps will be removed from function components', | ||
| 'Warning: An update to', | ||
| ], | ||
| }; |
| const normalizedQuantity = parseInt(quantity, 10) === 0 ? null : quantity; | ||
|
|
||
| const totalPerYear = normalizedQuantity && normalizedQuantity > 0 && yearlyCostPerSubscriptionPerUser |
| useEffect(() => { | ||
| if (!testimonials.length) { return; } | ||
|
|
||
| let available = testimonials.filter( | ||
| (t) => t.uuid && !shownTestimonialsRef.current.includes(t.uuid), |
| isEssentials | ||
| pricePerUser={ESSENTIALS_PRICE_PER_USER} | ||
| priceLabel="Essentials subscription, price per user, paid yearly." | ||
| quantity={normalizedQuantity} |
PurchaseSummaryupdateclip.mp4
Pusrchase.summaryclip2.mp4
Figma Link: https://www.figma.com/design/KAda9wZwM0uiTxVmNqF7ip/Subscription-Self-Service-V2?node-id=4131-207&t=cMZ9GAd4nr59dU5n-0