From 8580ba0ace87ad584e60d46cf91bfaddfa606cf3 Mon Sep 17 00:00:00 2001 From: Venkataramana Devathoti <114353712+Venkat-Entropik@users.noreply.github.com> Date: Wed, 1 Apr 2026 13:35:18 +0530 Subject: [PATCH] feat(client): ensure donate button is always visible (#66706) Co-authored-by: Venkat --- .../Header/components/nav-links.tsx | 43 +------- .../Header/components/universal-nav.css | 45 +++++++- .../Header/components/universal-nav.test.tsx | 103 ++++++++++++++++++ .../Header/components/universal-nav.tsx | 29 +++++ e2e/header.spec.ts | 31 ++++-- 5 files changed, 196 insertions(+), 55 deletions(-) create mode 100644 client/src/components/Header/components/universal-nav.test.tsx diff --git a/client/src/components/Header/components/nav-links.tsx b/client/src/components/Header/components/nav-links.tsx index 9212130c4b2..8f3b6774399 100644 --- a/client/src/components/Header/components/nav-links.tsx +++ b/client/src/components/Header/components/nav-links.tsx @@ -13,7 +13,6 @@ import { openSignoutModal, toggleTheme } from '../../../redux/actions'; import { Link } from '../../helpers'; import { LocalStorageThemes } from '../../../redux/types'; import { themeSelector } from '../../../redux/selectors'; -import SupporterBadge from '../../../assets/icons/supporter-badge'; export interface NavLinksProps { displayMenu: boolean; @@ -36,42 +35,6 @@ const mapStateToProps = createSelector( (theme: LocalStorageThemes) => ({ theme }) ); -interface DonateButtonProps { - isUserDonating: boolean | undefined; - handleMenuKeyDown: (event: React.KeyboardEvent) => void; -} - -const DonateButton = ({ - isUserDonating, - handleMenuKeyDown -}: DonateButtonProps) => { - const { t } = useTranslation(); - return ( -
  • - - {isUserDonating ? ( - <> - {t('buttons.supporters')} - - - ) : ( - <>{t('buttons.donate')} - )} - -
  • - ); -}; - function NavLinks({ menuButtonRef, openSignoutModal, @@ -82,7 +45,7 @@ function NavLinks({ toggleTheme }: NavLinksProps) { const { t } = useTranslation(); - const { isDonating: isUserDonating, username: currentUserName } = user || {}; + const { username: currentUserName } = user || {}; // the accessibility tree just needs a little more time to pick up the change. // This function allows us to set aria-expanded to false and then delay just a bit before setting focus on the button @@ -143,10 +106,6 @@ function NavLinks({ data-playwright-test-label='header-menu' className={`nav-list${displayMenu ? ' display-menu' : ''}`} > -
  • {t('buttons.curriculum')} diff --git a/client/src/components/Header/components/universal-nav.css b/client/src/components/Header/components/universal-nav.css index 95c83745950..f0ac48ce7f1 100644 --- a/client/src/components/Header/components/universal-nav.css +++ b/client/src/components/Header/components/universal-nav.css @@ -84,8 +84,9 @@ /** * Site header language list + * Using ~ so it still works if more items are added between the button and list. */ -.lang-button-nav[aria-expanded='true'] + .nav-list { +.lang-button-nav[aria-expanded='true'] ~ .nav-list { -ms-overflow-style: none; display: block; max-height: calc(100vh - var(--header-height)); @@ -94,7 +95,7 @@ top: calc(var(--header-height)); } -.lang-button-nav[aria-expanded='true'] + .nav-list::-webkit-scrollbar { +.lang-button-nav[aria-expanded='true'] ~ .nav-list::-webkit-scrollbar { display: none; } @@ -258,6 +259,33 @@ li > button.nav-link-signout:not([aria-disabled='true']):is(:hover, :focus) { border: 1px solid var(--gray-00); } +.nav-donate-btn .menu-btn-icon { + display: inline-flex; + align-items: center; +} + +.nav-donate-btn .menu-btn-text { + display: none; +} + +@media (min-width: 601px) { + .nav-donate-btn .menu-btn-icon { + display: none; + } + .nav-donate-btn .menu-btn-text { + display: inline-block; + } +} + +.nav-donate-btn .fa-heart { + color: #ff5e5e; + transition: transform 0.2s ease; +} + +.nav-donate-btn:hover .fa-heart { + transform: scale(1.2); +} + /** * User thumbnail */ @@ -367,7 +395,7 @@ li > button.nav-link-signout:not([aria-disabled='true']):is(:hover, :focus) { menu is collapsed. */ .universal-nav-right #toggle-button-nav[aria-expanded='false'] - + .fcc_searchBar { + ~ .fcc_searchBar { display: none; } @@ -375,7 +403,7 @@ li > button.nav-link-signout:not([aria-disabled='true']):is(:hover, :focus) { menu is collapsed. */ .universal-nav-right #toggle-button-nav[aria-expanded='false'] - + .fcc_searchBar + ~ .fcc_searchBar .ais-Hits { display: none; } @@ -423,12 +451,17 @@ li > button.nav-link-signout:not([aria-disabled='true']):is(:hover, :focus) { /** * Handle submenu containers collapsed and expanded states + + * We use ~ because the Donate button is now between the toggle button + * and the menu/search elements, so they are no longer right next to each other. */ -#universal-nav button[aria-expanded='false'] + div { +#universal-nav button[aria-expanded='false'] ~ .nav-list, +#universal-nav button[aria-expanded='false'] ~ .fcc_searchBar { display: none; } -#universal-nav button[aria-expanded='true'] + div { +#universal-nav button[aria-expanded='true'] ~ .nav-list, +#universal-nav button[aria-expanded='true'] ~ .fcc_searchBar { display: block; } diff --git a/client/src/components/Header/components/universal-nav.test.tsx b/client/src/components/Header/components/universal-nav.test.tsx new file mode 100644 index 00000000000..f4067dbbe8c --- /dev/null +++ b/client/src/components/Header/components/universal-nav.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Provider } from 'react-redux'; +import { createStore } from '../../../redux/create-store'; +import UniversalNav from './universal-nav'; + +vi.mock('@loadable/component', () => ({ + default: () => { + const LazyComponent = () => null; + LazyComponent.displayName = 'Loadable'; + return LazyComponent; + } +})); + +vi.mock('../../../utils/get-words'); +vi.mock('../../../analytics'); + +const baseProps = { + displayMenu: false, + showMenu: vi.fn(), + hideMenu: vi.fn(), + menuButtonRef: { current: null } as React.RefObject, + searchBarRef: { current: null } as React.RefObject, + fetchState: { pending: false }, + pathname: '/learn' +}; + +const baseUser = { + username: 'test-user', + picture: 'https://freecodecamp.org/image.png', + yearsTopContributor: [] +}; + +const nonDonatingUser = { ...baseUser, isDonating: false }; +const donatingUser = { ...baseUser, isDonating: true }; + +const getByLabel = (label: string) => + within(screen.getByRole('navigation')).getByRole('link', { + hidden: true, + name: (_: string, el: Element) => + el.getAttribute('data-playwright-test-label') === label + }); + +const queryByLabel = (label: string) => + within(screen.getByRole('navigation')).queryByRole('link', { + hidden: true, + name: (_: string, el: Element) => + el.getAttribute('data-playwright-test-label') === label + }); + +const renderNav = ( + user: typeof baseUser & { isDonating: boolean }, + pending = false +) => + render( + + + + ); + +describe('', () => { + describe.each([ + { + label: 'non-donating user', + user: nonDonatingUser, + visibleBtn: { testLabel: 'header-donate-button', href: '/donate' }, + hiddenBtn: { testLabel: 'header-support-button' } + }, + { + label: 'donating user', + user: donatingUser, + visibleBtn: { testLabel: 'header-support-button', href: '/supporters' }, + hiddenBtn: { testLabel: 'header-donate-button' } + } + ])('$label', ({ user, visibleBtn, hiddenBtn }) => { + it(`renders ${visibleBtn.testLabel}`, () => { + renderNav(user); + expect(getByLabel(visibleBtn.testLabel)).toBeInTheDocument(); + }); + + it(`links to ${visibleBtn.href}`, () => { + renderNav(user); + expect(getByLabel(visibleBtn.testLabel)).toHaveAttribute( + 'href', + visibleBtn.href + ); + }); + + it(`does not render ${hiddenBtn.testLabel}`, () => { + renderNav(user); + expect(queryByLabel(hiddenBtn.testLabel)).not.toBeInTheDocument(); + }); + }); + + describe('Loading state', () => { + it('renders no donate or supporters button when pending', () => { + renderNav(nonDonatingUser, true); + expect(queryByLabel('header-donate-button')).not.toBeInTheDocument(); + expect(queryByLabel('header-support-button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/Header/components/universal-nav.tsx b/client/src/components/Header/components/universal-nav.tsx index 5225dcd8295..ab46b0d0c76 100644 --- a/client/src/components/Header/components/universal-nav.tsx +++ b/client/src/components/Header/components/universal-nav.tsx @@ -1,3 +1,5 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faHeart } from '@fortawesome/free-solid-svg-icons'; import Loadable from '@loadable/component'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -6,6 +8,7 @@ import { isLanding } from '../../../utils/path-parsers'; import { Link, SkeletonSprite } from '../../helpers'; import { SEARCH_EXPOSED_WIDTH } from '../../../../config/misc'; import FreeCodeCampLogo from '../../../assets/icons/freecodecamp-logo'; +import SupporterBadge from '../../../assets/icons/supporter-badge'; import MenuButton from './menu-button'; import NavLinks from './nav-links'; import AuthOrProfile from './auth-or-profile'; @@ -54,6 +57,8 @@ const UniversalNav = ({ ) : ( ); + + const isDonating: boolean = user?.isDonating; return (