Просмотр исходного кода

Merge pull request #20140 from overleaf/rd-footer-react

[web] Migrate the footer to React

GitOrigin-RevId: 9571d7b3ef9a267fb8250956f821b1ee34ce07c7
Rebeka Dekany 1 год назад
Родитель
Сommit
f6213de83b

+ 8 - 1
services/web/app/views/layout-react.pug

@@ -33,6 +33,10 @@ block append meta
 			adminUrl: settings.adminUrl,
 			items: cloneAndTranslateText(nav.header_extras)
 		})
+		meta(name="ol-footer" data-type="json" content={
+			subdomainLang: settings.i18n.subdomainLang,
+			translatedLanguages: settings.translatedLanguages
+		})
 
 block body
 	if (typeof suppressNavbar === "undefined")
@@ -47,7 +51,10 @@ block body
 		if showThinFooter
 			include layout/footer-marketing
 		else
-			include layout/fat-footer
+			if bootstrapVersion === 5
+				include layout/fat-footer-react-bootstrap-5
+			else
+				include layout/fat-footer
 
 	if (typeof suppressCookieBanner === "undefined")
 		include _cookie_banner

+ 1 - 0
services/web/app/views/layout/fat-footer-react-bootstrap-5.pug

@@ -0,0 +1 @@
+#fat-footer-container

+ 32 - 0
services/web/frontend/extracted-translations.json

@@ -2,8 +2,14 @@
   "1_2_width": "",
   "1_4_width": "",
   "3_4_width": "",
+  "About": "",
   "Account": "",
   "Account Settings": "",
+  "Documentation": "",
+  "Get Involved": "",
+  "Help": "",
+  "Learn": "",
+  "Plans and Pricing": "",
   "a_custom_size_has_been_used_in_the_latex_code": "",
   "a_fatal_compile_error_that_completely_blocks_compilation": "",
   "a_file_with_that_name_already_exists_and_will_be_overriden": "",
@@ -96,6 +102,7 @@
   "anonymous": "",
   "anyone_with_link_can_edit": "",
   "anyone_with_link_can_view": "",
+  "app_on_x": "",
   "apply_suggestion": "",
   "archive": "",
   "archive_projects": "",
@@ -124,6 +131,7 @@
   "back_to_editor": "",
   "back_to_subscription": "",
   "back_to_your_projects": "",
+  "become_an_advisor": "",
   "before_you_use_the_ai_error_assistant": "",
   "beta_program_already_participating": "",
   "beta_program_benefits": "",
@@ -132,6 +140,7 @@
   "binary_history_error": "",
   "blank_project": "",
   "blocked_filename": "",
+  "blog": "",
   "browser": "",
   "bulk_accept_confirm": "",
   "bulk_reject_confirm": "",
@@ -157,6 +166,7 @@
   "card_details_are_not_valid": "",
   "card_must_be_authenticated_by_3dsecure": "",
   "card_payment": "",
+  "careers": "",
   "category_arrows": "",
   "category_greek": "",
   "category_misc": "",
@@ -223,6 +233,7 @@
   "compile_terminated_by_user": "",
   "compiler": "",
   "compiling": "",
+  "compliance": "",
   "compromised_password": "",
   "configure_sso": "",
   "confirm": "",
@@ -465,7 +476,14 @@
   "following_paths_conflict": "",
   "font_family": "",
   "font_size": "",
+  "footer_about_us": "",
+  "footer_contact_us": "",
+  "footer_navigation": "",
+  "for_enterprise": "",
+  "for_individuals_and_groups": "",
   "for_more_information_see_managed_accounts_section": "",
+  "for_students": "",
+  "for_universities": "",
   "format": "",
   "found_matching_deleted_users": "",
   "free_7_day_trial_billed_annually": "",
@@ -623,6 +641,8 @@
   "hotkey_undo": "",
   "hotkeys": "",
   "how_it_works": "",
+  "how_to_create_tables": "",
+  "how_to_insert_images": "",
   "how_we_use_your_data": "",
   "how_we_use_your_data_explanation": "",
   "i_want_to_stay": "",
@@ -694,6 +714,7 @@
   "ip_address": "",
   "is_email_affiliated": "",
   "issued_on": "",
+  "join_beta_program": "",
   "join_now": "",
   "join_overleaf_labs": "",
   "join_project": "",
@@ -723,6 +744,7 @@
   "last_used": "",
   "latam_discount_modal_info": "",
   "latam_discount_modal_title": "",
+  "latex_in_thirty_minutes": "",
   "latex_places_figures_according_to_a_special_algorithm": "",
   "latex_places_tables_according_to_a_special_algorithm": "",
   "layout": "",
@@ -742,6 +764,7 @@
   "length_unit": "",
   "let_us_know": "",
   "let_us_know_how_we_can_help": "",
+  "let_us_know_what_you_think": "",
   "lets_fix_your_errors": "",
   "library": "",
   "license_for_educational_purposes": "",
@@ -949,6 +972,7 @@
   "other": "",
   "other_logs_and_files": "",
   "other_output_files": "",
+  "our_values": "",
   "out_of_sync": "",
   "out_of_sync_detail": "",
   "output_file": "",
@@ -1016,8 +1040,10 @@
   "plus_more": "",
   "postal_code": "",
   "premium_feature": "",
+  "premium_features": "",
   "premium_plan_label": "",
   "presentation_mode": "",
+  "press_and_awards": "",
   "previous_page": "",
   "price": "",
   "primarily_work_study_question": "",
@@ -1028,6 +1054,7 @@
   "primarily_work_study_question_university_school": "",
   "primary_certificate": "",
   "priority_support": "",
+  "privacy_and_terms": "",
   "private": "",
   "problem_talking_to_publishing_service": "",
   "problem_with_subscription_contact_us": "",
@@ -1227,6 +1254,7 @@
   "select_a_file": "",
   "select_a_file_figure_modal": "",
   "select_a_group_optional": "",
+  "select_a_language": "",
   "select_a_new_owner_for_projects": "",
   "select_a_payment_method": "",
   "select_a_project": "",
@@ -1567,6 +1595,7 @@
   "turn_off_link_sharing": "",
   "turn_on": "",
   "turn_on_link_sharing": "",
+  "tutorials": "",
   "unarchive": "",
   "uncategorized": "",
   "uncategorized_projects": "",
@@ -1675,6 +1704,8 @@
   "we_do_not_share_personal_information": "",
   "we_logged_you_in": "",
   "we_sent_new_code": "",
+  "webinars": "",
+  "website_status": "",
   "wed_love_you_to_stay": "",
   "welcome_to_sl": "",
   "were_making_some_changes_to_project_sharing_this_means_you_will_be_visible": "",
@@ -1688,6 +1719,7 @@
   "what_happens_when_sso_is_enabled": "",
   "what_should_we_call_you": "",
   "when_you_tick_the_include_caption_box": "",
+  "why_latex": "",
   "wide": "",
   "will_lose_edit_access_on_date": "",
   "with_premium_subscription_you_also_get": "",

+ 11 - 4
services/web/frontend/js/features/header-footer-react/index.tsx

@@ -1,9 +1,16 @@
 import ReactDOM from 'react-dom'
-import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
 import getMeta from '@/utils/meta'
+import DefaultNavbar from '@/features/ui/components/bootstrap-5/navbar/default-navbar'
+import FatFooter from '@/features/ui/components/bootstrap-5/footer/fat-footer'
 
-const element = document.getElementById('navbar-container')
-if (element) {
+const navbarElement = document.getElementById('navbar-container')
+if (navbarElement) {
   const navbarProps = getMeta('ol-navbar')
-  ReactDOM.render(<DefaultNavbar {...navbarProps} />, element)
+  ReactDOM.render(<DefaultNavbar {...navbarProps} />, navbarElement)
+}
+
+const footerElement = document.getElementById('fat-footer-container')
+if (footerElement) {
+  const footerProps = getMeta('ol-footer')
+  ReactDOM.render(<FatFooter {...footerProps} />, footerElement)
 }

+ 86 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer-base.tsx

@@ -0,0 +1,86 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import LanguagePicker from '../language-picker'
+import Icon from '@/shared/components/icon'
+
+type FooterLinkProps = {
+  href: string
+  children: React.ReactNode
+}
+
+type SocialMediaLinkProps = {
+  href: string
+  icon: string
+  accessibilityLabel: string
+}
+
+function FatFooterBase() {
+  const { t } = useTranslation()
+  const currentYear = new Date().getFullYear()
+
+  return (
+    <footer className="fat-footer-base">
+      <div className="fat-footer-base-section fat-footer-base-meta">
+        <div className="fat-footer-base-item">
+          <div className="fat-footer-base-copyright">
+            © {currentYear} Overleaf
+          </div>
+          <FooterBaseLink href="/legal">
+            {t('privacy_and_terms')}
+          </FooterBaseLink>
+          <FooterBaseLink href="https://www.digital-science.com/security-certifications/">
+            {t('compliance')}
+          </FooterBaseLink>
+        </div>
+        <div className="fat-footer-base-item fat-footer-base-language">
+          <LanguagePicker />
+        </div>
+      </div>
+      <div className="fat-footer-base-section fat-footer-base-social">
+        <div className="fat-footer-base-item">
+          <SocialMediaLink
+            href="https://twitter.com/overleaf"
+            icon="twitter-square"
+            accessibilityLabel={t('app_on_x', { social: 'Twitter' })}
+          />
+          <SocialMediaLink
+            href="https://www.facebook.com/overleaf.editor"
+            icon="facebook-square"
+            accessibilityLabel={t('app_on_x', { social: 'Facebook' })}
+          />
+          <SocialMediaLink
+            href="https://www.linkedin.com/company/writelatex-limited"
+            icon="linkedin-square"
+            accessibilityLabel={t('app_on_x', { social: 'LinkedIn' })}
+          />
+        </div>
+      </div>
+    </footer>
+  )
+}
+
+function FooterBaseLink({ href, children }: FooterLinkProps) {
+  return (
+    <a className="fat-footer-link" href={href}>
+      {children}
+    </a>
+  )
+}
+
+function SocialMediaLink({
+  href,
+  icon,
+  accessibilityLabel,
+}: SocialMediaLinkProps) {
+  return (
+    <a className="fat-footer-social" href={href}>
+      <Icon
+        type={icon}
+        className="fa"
+        accessibilityLabel={accessibilityLabel}
+      />
+    </a>
+  )
+}
+
+export default FatFooterBase

+ 133 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/footer/fat-footer.tsx

@@ -0,0 +1,133 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import FatFooterBase from './fat-footer-base'
+import { FatFooterMetadata } from '../../types/fat-footer-metadata'
+
+type FooterLinkProps = {
+  href: string
+  label: string
+}
+
+type FooterSectionProps = {
+  title: string
+  links: FooterLinkProps[]
+}
+
+function FatFooter(props: FatFooterMetadata) {
+  const { t } = useTranslation()
+  const hideFatFooter = false
+
+  const sections = [
+    {
+      title: t('About'),
+      links: [
+        { href: '/about', label: t('footer_about_us') },
+        { href: '/about/values', label: t('our_values') },
+        { href: '/about/careers', label: t('careers') },
+        { href: '/for/press', label: t('press_and_awards') },
+        { href: '/blog', label: t('blog') },
+      ],
+    },
+    {
+      title: t('Learn'),
+      links: [
+        {
+          href: '/learn/latex/Learn_LaTeX_in_30_minutes',
+          label: t('latex_in_thirty_minutes'),
+        },
+        { href: '/latex/templates', label: t('templates') },
+        { href: '/events/webinars', label: t('webinars') },
+        { href: '/learn/latex/Tutorials', label: t('tutorials') },
+        {
+          href: '/learn/latex/Inserting_Images',
+          label: t('how_to_insert_images'),
+        },
+        { href: '/learn/latex/Tables', label: t('how_to_create_tables') },
+      ],
+    },
+    {
+      title: t('Plans and Pricing'),
+      links: [
+        {
+          href: '/learn/how-to/Overleaf_premium_features',
+          label: t('premium_features'),
+        },
+        {
+          href: '/user/subscription/plans?itm_referrer=footer-for-indv-groups',
+          label: t('for_individuals_and_groups'),
+        },
+        { href: '/for/enterprises', label: t('for_enterprise') },
+        { href: '/for/universities', label: t('for_universities') },
+        {
+          href: '/user/subscription/plans?itm_referrer=footer-for-students#student-annual',
+          label: t('for_students'),
+        },
+      ],
+    },
+    {
+      title: t('Get Involved'),
+      links: [
+        { href: '/for/community/advisors', label: t('become_an_advisor') },
+        {
+          href: 'https://forms.gle/67PSpN1bLnjGCmPQ9',
+          label: t('let_us_know_what_you_think'),
+        },
+        { href: '/beta/participate', label: t('join_beta_program') },
+      ],
+    },
+    {
+      title: t('Help'),
+      links: [
+        { href: '/about/why-latex', label: t('why_latex') },
+        { href: '/learn', label: t('Documentation') },
+        { href: '/contact', label: t('footer_contact_us') },
+        { href: 'https://status.overleaf.com/', label: t('website_status') },
+      ],
+    },
+  ]
+
+  return (
+    <footer className="fat-footer hidden-print">
+      <div
+        role="navigation"
+        aria-label={t('footer_navigation')}
+        className="fat-footer-container"
+      >
+        <div className={`fat-footer-sections ${hideFatFooter ? 'hidden' : ''}`}>
+          <div className="footer-section" id="footer-brand">
+            <a href="/" aria-label={t('overleaf')} className="footer-brand">
+              <span className="visually-hidden">{t('overleaf')}</span>
+            </a>
+          </div>
+
+          {sections.map(section => (
+            <div className="footer-section" key={section.title}>
+              <FooterSection title={section.title} links={section.links} />
+            </div>
+          ))}
+        </div>
+
+        <FatFooterBase />
+      </div>
+    </footer>
+  )
+}
+
+function FooterSection({ title, links }: FooterSectionProps) {
+  const { t } = useTranslation()
+
+  return (
+    <>
+      <h2 className="footer-section-heading">{t(title)}</h2>
+      <ul className="list-unstyled">
+        {links.map(link => (
+          <li key={link.href}>
+            <a href={link.href}>{t(link.label)}</a>
+          </li>
+        ))}
+      </ul>
+    </>
+  )
+}
+
+export default FatFooter

+ 60 - 0
services/web/frontend/js/features/ui/components/bootstrap-5/language-picker.tsx

@@ -0,0 +1,60 @@
+import React from 'react'
+import {
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from './dropdown-menu'
+import { useTranslation } from 'react-i18next'
+import getMeta from '@/utils/meta'
+import Icon from '@/shared/components/icon'
+
+function LanguagePicker() {
+  const { t } = useTranslation()
+
+  const currentLangCode = getMeta('ol-i18n').currentLangCode
+  const translatedLanguages = getMeta('ol-footer').translatedLanguages
+  const subdomainLang = getMeta('ol-footer').subdomainLang
+  const currentUrlWithQueryParams = window.location.pathname
+
+  return (
+    <Dropdown>
+      <DropdownToggle
+        id="language-picker-toggle"
+        aria-label={t('select_a_language')}
+        data-bs-toggle="dropdown"
+        className="btn-inline-link"
+        variant="link"
+      >
+        <Icon
+          type="language"
+          className="fa fa-fw"
+          accessibilityLabel={t('select_a_language')}
+        />
+        {translatedLanguages?.[currentLangCode]}
+      </DropdownToggle>
+
+      <DropdownMenu className="sm" aria-labelledby="language-picker-toggle">
+        {subdomainLang &&
+          Object.entries(subdomainLang).map(([subdomain, subdomainDetails]) => {
+            if (!subdomainDetails || !subdomainDetails.lngCode) return null
+            const isActive = subdomainDetails.lngCode === currentLangCode
+            return (
+              <li role="none" key={subdomain}>
+                <DropdownItem
+                  href={`${subdomainDetails.url}${currentUrlWithQueryParams}`}
+                  active={isActive}
+                  aria-current={isActive ? 'true' : false}
+                  trailingIcon={isActive ? 'check' : null}
+                >
+                  {translatedLanguages?.[subdomainDetails.lngCode]}
+                </DropdownItem>
+              </li>
+            )
+          })}
+      </DropdownMenu>
+    </Dropdown>
+  )
+}
+
+export default LanguagePicker

+ 7 - 0
services/web/frontend/js/features/ui/components/types/fat-footer-metadata.ts

@@ -0,0 +1,7 @@
+import type { SubdomainLang } from '@/features/ui/components/types/fat-footer'
+
+export type FatFooterMetadata = {
+  subdomainLang?: SubdomainLang
+  translatedLanguages: { [key: string]: string }
+  currentLangCode: string
+}

+ 8 - 0
services/web/frontend/js/features/ui/components/types/fat-footer.ts

@@ -0,0 +1,8 @@
+export interface SubdomainDetails {
+  lngCode: string
+  url: string
+}
+
+export interface SubdomainLang {
+  [subdomain: string]: SubdomainDetails
+}

+ 1 - 1
services/web/frontend/js/features/ui/components/types/split-button-props.ts

@@ -15,7 +15,7 @@ type SplitButtonItemProps = Pick<
 
 export type SplitButtonVariants = Extract<
   ButtonProps['variant'],
-  'primary' | 'secondary' | 'danger'
+  'primary' | 'secondary' | 'danger' | 'link'
 >
 
 export type SplitButtonProps = PropsWithChildren<{

+ 2 - 1
services/web/frontend/js/utils/meta.ts

@@ -47,7 +47,7 @@ import { Subscription as ProjectDashboardSubscription } from '../../../types/pro
 import { ThirdPartyIds } from '../../../types/third-party-ids'
 import { Publisher } from '../../../types/subscription/dashboard/publisher'
 import { DefaultNavbarMetadata } from '@/features/ui/components/types/default-navbar-metadata'
-
+import { FatFooterMetadata } from '@/features/ui/components/types/fat-footer-metadata'
 export interface Meta {
   'ol-ExposedSettings': ExposedSettings
   'ol-allInReconfirmNotificationPeriods': UserEmailData[]
@@ -83,6 +83,7 @@ export interface Meta {
   'ol-error': { name: string } | undefined
   'ol-expired': boolean
   'ol-features': Features
+  'ol-footer': FatFooterMetadata
   'ol-fromPlansPage': boolean
   'ol-gitBridgeEnabled': boolean
   'ol-gitBridgePublicBaseUrl': string

+ 6 - 0
services/web/frontend/stylesheets/bootstrap-5/components/footer.scss

@@ -139,6 +139,12 @@ footer.site-footer {
 
     #language-picker-toggle {
       color: var(--content-secondary-dark);
+      cursor: pointer;
+      text-decoration: none;
+
+      &::after {
+        display: none;
+      }
     }
 
     .fat-footer-base-meta a:not(.dropdown-toggle) {

+ 1 - 0
services/web/locales/en.json

@@ -1770,6 +1770,7 @@
   "select_a_file": "Select a File",
   "select_a_file_figure_modal": "Select a file",
   "select_a_group_optional": "Select a Group (optional)",
+  "select_a_language": "Select a language",
   "select_a_new_owner_for_projects": "Select a new owner for this user’s projects",
   "select_a_payment_method": "Select a payment method",
   "select_a_project": "Select a Project",

+ 50 - 0
services/web/test/frontend/components/shared/language-picker.spec.tsx

@@ -0,0 +1,50 @@
+import React from 'react'
+import '../../helpers/bootstrap-5'
+import LanguagePicker from '../../../../frontend/js/features/ui/components/bootstrap-5/language-picker'
+import getMeta from '@/utils/meta'
+import exposedSettings from '../../../../modules/admin-panel/test/frontend/js/features/user/data/exposedSettings'
+
+describe('LanguagePicker', function () {
+  beforeEach(function () {
+    window.metaAttributesCache.set('ol-i18n', {
+      currentLangCode: 'en',
+    })
+    window.metaAttributesCache.set('ol-footer', {
+      translatedLanguages: {
+        en: 'English',
+        fr: 'Français',
+        es: 'Español',
+      },
+      subdomainLang: {
+        en: { lngCode: 'en', url: 'overleaf.com' },
+        fr: { lngCode: 'fr', url: 'fr.overleaf.com' },
+        es: { lngCode: 'es', url: 'es.overleaf.com' },
+      },
+    })
+
+    Object.assign(getMeta('ol-ExposedSettings'), exposedSettings)
+  })
+
+  it('renders the language picker with the current language', function () {
+    cy.mount(<LanguagePicker />)
+    cy.get('#language-picker-toggle').should('contain', 'English')
+  })
+
+  it('opens the dropdown and lists available languages', function () {
+    cy.mount(<LanguagePicker />)
+    cy.get('#language-picker-toggle').click()
+
+    cy.get('.dropdown-menu').within(() => {
+      cy.contains('English').should('exist')
+      cy.contains('Français').should('exist')
+      cy.contains('Español').should('exist')
+    })
+  })
+
+  it('changes the language and updates the URL when a language is selected', function () {
+    cy.mount(<LanguagePicker />)
+    cy.get('#language-picker-toggle').should('exist').click()
+    cy.contains('Français').click()
+    cy.url().should('include', 'fr.overleaf.com')
+  })
+})