From 4fe02e3f7f4bb9c684cb86fe161dcb7bcea76b9e Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Tue, 15 Aug 2023 08:46:43 -0500 Subject: [PATCH] Webinars page (#2450) * Disentangle Search Bar from Blog page * Create use-data; use for blog-context * Explore sections and search bar are components shared by blog and webinars * Make Breadcrumb a component * Latest webinars page * Explore pages * Search page * Fix dependency lists * Remove use of any * Code review * 2 --- src/app/components/breadcrumb/breadcrumb.scss | 9 ++ src/app/components/breadcrumb/breadcrumb.tsx | 40 ++++++ .../explore-by-collection.scss} | 7 +- .../explore-by-collection.tsx | 46 +++++++ .../components/explore-by-collection/types.ts | 5 + .../explore-by-subject.scss} | 21 ++- .../explore-by-subject/explore-by-subject.tsx | 46 +++++++ .../components/explore-by-subject/types.ts | 5 + .../explore-page/section/section.scss} | 0 .../explore-page/section/section.tsx | 30 +++++ .../components/jsx-helpers/build-context.js | 4 +- src/app/components/jsx-helpers/loader-page.js | 2 +- .../link-with-chevron/link-with-chevron.js | 2 +- .../components/paginator/simple-paginator.js | 18 +++ .../search-bar/search-bar.js | 17 +-- src/app/components/search-bar/search-bar.scss | 80 ++++++++++++ .../components/search-bar/search-context.ts | 27 ++++ src/app/components/shell/router.js | 1 + src/app/helpers/page-data-utils.js | 2 +- src/app/helpers/use-data.ts | 88 +++++++++++++ src/app/helpers/use-document-head.js | 2 +- src/app/pages/blog/blog-context.js | 43 +++--- src/app/pages/blog/blog.js | 23 ++-- src/app/pages/blog/blog.scss | 17 +-- .../pages/blog/explore-page/explore-page.js | 33 ++--- src/app/pages/blog/explore/by-subject.js | 34 ----- src/app/pages/blog/explore/collections.js | 32 ----- .../latest-blog-posts/latest-blog-posts.js | 30 +---- .../latest-blog-posts/latest-blog-posts.scss | 8 -- .../pages/blog/more-stories/more-stories.js | 7 +- .../blog/pinned-article/pinned-article.js | 7 +- src/app/pages/blog/search-bar/search-bar.scss | 81 ------------ .../pages/blog/search-bar/search-context.js | 21 --- .../blog/section-header/section-header.js | 11 -- .../webinars/explore-page/explore-page.scss | 9 ++ .../webinars/explore-page/explore-page.tsx | 93 +++++++++++++ .../explore-page/heading-and-search-bar.tsx | 30 +++++ src/app/pages/webinars/import-explore-page.js | 4 + src/app/pages/webinars/import-latest-page.js | 4 + src/app/pages/webinars/import-main-page.js | 4 + src/app/pages/webinars/import-search-page.js | 4 + .../webinars/latest-page/latest-page.tsx | 48 +++++++ .../pages/webinars/main-page/main-page.tsx | 32 +++++ .../webinars/search-page/search-page.tsx | 66 ++++++++++ src/app/pages/webinars/types.ts | 31 +++++ .../webinar-cards/latest-webinars.scss | 94 ++++++++++++++ .../webinar-cards/latest-webinars.tsx | 115 +++++++++++++++++ src/app/pages/webinars/webinar-context.tsx | 97 ++++++++++++++ .../webinars/webinar-list/webinar-list.js | 98 -------------- .../webinars/webinar-list/webinar-list.scss | 122 ------------------ src/app/pages/webinars/webinars.js | 77 ----------- src/app/pages/webinars/webinars.scss | 106 ++------------- src/app/pages/webinars/webinars.tsx | 37 ++++++ 53 files changed, 1164 insertions(+), 706 deletions(-) create mode 100644 src/app/components/breadcrumb/breadcrumb.scss create mode 100644 src/app/components/breadcrumb/breadcrumb.tsx rename src/app/{pages/blog/explore/collections.scss => components/explore-by-collection/explore-by-collection.scss} (91%) create mode 100644 src/app/components/explore-by-collection/explore-by-collection.tsx create mode 100644 src/app/components/explore-by-collection/types.ts rename src/app/{pages/blog/explore/by-subject.scss => components/explore-by-subject/explore-by-subject.scss} (78%) create mode 100644 src/app/components/explore-by-subject/explore-by-subject.tsx create mode 100644 src/app/components/explore-by-subject/types.ts rename src/app/{pages/blog/section-header/section-header.scss => components/explore-page/section/section.scss} (100%) create mode 100644 src/app/components/explore-page/section/section.tsx rename src/app/{pages/blog => components}/search-bar/search-bar.js (84%) create mode 100644 src/app/components/search-bar/search-bar.scss create mode 100644 src/app/components/search-bar/search-context.ts create mode 100644 src/app/helpers/use-data.ts delete mode 100644 src/app/pages/blog/explore/by-subject.js delete mode 100644 src/app/pages/blog/explore/collections.js delete mode 100644 src/app/pages/blog/search-bar/search-bar.scss delete mode 100644 src/app/pages/blog/search-bar/search-context.js delete mode 100644 src/app/pages/blog/section-header/section-header.js create mode 100644 src/app/pages/webinars/explore-page/explore-page.scss create mode 100644 src/app/pages/webinars/explore-page/explore-page.tsx create mode 100644 src/app/pages/webinars/explore-page/heading-and-search-bar.tsx create mode 100644 src/app/pages/webinars/import-explore-page.js create mode 100644 src/app/pages/webinars/import-latest-page.js create mode 100644 src/app/pages/webinars/import-main-page.js create mode 100644 src/app/pages/webinars/import-search-page.js create mode 100644 src/app/pages/webinars/latest-page/latest-page.tsx create mode 100644 src/app/pages/webinars/main-page/main-page.tsx create mode 100644 src/app/pages/webinars/search-page/search-page.tsx create mode 100644 src/app/pages/webinars/types.ts create mode 100644 src/app/pages/webinars/webinar-cards/latest-webinars.scss create mode 100644 src/app/pages/webinars/webinar-cards/latest-webinars.tsx create mode 100644 src/app/pages/webinars/webinar-context.tsx delete mode 100644 src/app/pages/webinars/webinar-list/webinar-list.js delete mode 100644 src/app/pages/webinars/webinar-list/webinar-list.scss delete mode 100644 src/app/pages/webinars/webinars.js create mode 100644 src/app/pages/webinars/webinars.tsx diff --git a/src/app/components/breadcrumb/breadcrumb.scss b/src/app/components/breadcrumb/breadcrumb.scss new file mode 100644 index 000000000..8f40d2ee2 --- /dev/null +++ b/src/app/components/breadcrumb/breadcrumb.scss @@ -0,0 +1,9 @@ +.breadcrumb { + align-items: center; + font-weight: bold; + display: flex; + gap: 1rem; + margin: 3rem 0 2rem; + text-decoration: none; + width: 100%; +} diff --git a/src/app/components/breadcrumb/breadcrumb.tsx b/src/app/components/breadcrumb/breadcrumb.tsx new file mode 100644 index 000000000..2cc7f0151 --- /dev/null +++ b/src/app/components/breadcrumb/breadcrumb.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import {Link, useNavigate, useLocation} from 'react-router-dom'; +import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; +import {faChevronLeft} from '@fortawesome/free-solid-svg-icons/faChevronLeft'; +import './breadcrumb.scss'; + +type BreadcrumbArgs = { + name: string; +}; + +export default function Breadcrumb({name}: BreadcrumbArgs) { + const {pathname} = useLocation(); + // Remove everything after the first slash that follows a character + const topLevel = pathname.replace(/(?<=.)\/.*/, ''); + const goBack = useGoBack(topLevel); + + return ( + + + Back to Main {name} + + ); +} + +function useGoBack(path: string) { + const navigate = useNavigate(); + const {state} = useLocation(); + + return React.useCallback( + (e: React.MouseEvent) => { + if (state?.from === path) { + navigate(-1); + } else { + navigate(path, {replace: true}); + } + e.preventDefault(); + }, + [navigate, path, state?.from] + ); +} diff --git a/src/app/pages/blog/explore/collections.scss b/src/app/components/explore-by-collection/explore-by-collection.scss similarity index 91% rename from src/app/pages/blog/explore/collections.scss rename to src/app/components/explore-by-collection/explore-by-collection.scss index 0ef51f061..8181a0534 100644 --- a/src/app/pages/blog/explore/collections.scss +++ b/src/app/components/explore-by-collection/explore-by-collection.scss @@ -1,14 +1,10 @@ @import 'pattern-library/core/pattern-library/headers'; #explore-collections { - margin-top: 2rem; - width: 100%; - .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(25rem, 1fr)); gap: 3rem; - margin-top: 2rem; width: 100%; .card { @@ -19,6 +15,7 @@ text-decoration: none; .centered-image { + align-items: center; display: flex; justify-content: center; padding-bottom: 1rem; @@ -26,6 +23,7 @@ img { max-height: 14rem; + min-height: 8rem; max-width: 100%; } } @@ -37,6 +35,5 @@ text-align: center; } } - } } diff --git a/src/app/components/explore-by-collection/explore-by-collection.tsx b/src/app/components/explore-by-collection/explore-by-collection.tsx new file mode 100644 index 000000000..59428919d --- /dev/null +++ b/src/app/components/explore-by-collection/explore-by-collection.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {Link, useLocation} from 'react-router-dom'; +import {Collection} from './types'; +import './explore-by-collection.scss'; + +export default function ExploreCollections({ + collections, + analyticsNav +}: { + collections: Collection[]; + analyticsNav: string; +}) { + const {pathname} = useLocation(); + + return ( +
+

Explore collections

+
+ {collections.map((c) => ( + + ))} +
+
+ ); +} + +function CollectionLink({ + collection, + from +}: { + collection: Collection; + from: string; +}) { + return ( + +
+ +
+
{collection.name}
+ + ); +} diff --git a/src/app/components/explore-by-collection/types.ts b/src/app/components/explore-by-collection/types.ts new file mode 100644 index 000000000..eb9af918f --- /dev/null +++ b/src/app/components/explore-by-collection/types.ts @@ -0,0 +1,5 @@ +export type Collection = { + id: string; + name: string; + collectionImage?: string; +}; diff --git a/src/app/pages/blog/explore/by-subject.scss b/src/app/components/explore-by-subject/explore-by-subject.scss similarity index 78% rename from src/app/pages/blog/explore/by-subject.scss rename to src/app/components/explore-by-subject/explore-by-subject.scss index 17ed74776..c34d524b8 100644 --- a/src/app/pages/blog/explore/by-subject.scss +++ b/src/app/components/explore-by-subject/explore-by-subject.scss @@ -1,20 +1,17 @@ @import 'pattern-library/core/pattern-library/headers'; #explore-by-subject { - margin-bottom: 3rem; - width: 100%; - .subject-links { - margin-top: 5rem; display: grid; - gap: 3rem; - grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: 2rem; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); width: 100%; .card { @extend %card; - padding-bottom: 1rem; + padding: 0 0 1rem; + margin-top: 2rem; position: relative; text-align: center; @@ -37,6 +34,11 @@ } } + a { + color: inherit; + text-decoration: none; + } + .subject-name { @include set-font(body-large); @@ -47,11 +49,6 @@ line-height: 2.5rem; margin: 0.5rem 1rem; min-height: 5rem; - - a { - color: inherit; - text-decoration: none; - } } } } diff --git a/src/app/components/explore-by-subject/explore-by-subject.tsx b/src/app/components/explore-by-subject/explore-by-subject.tsx new file mode 100644 index 000000000..c7fd54e5f --- /dev/null +++ b/src/app/components/explore-by-subject/explore-by-subject.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {Link, useLocation} from 'react-router-dom'; +import {Category} from './types'; +import './explore-by-subject.scss'; + +export default function ExploreBySubject({ + categories, + analyticsNav +}: { + categories: Category[]; + analyticsNav: string; +}) { + const {pathname} = useLocation(); + + return ( +
+

Explore by subject

+
+ {categories.map((c) => ( + + ))} +
+
+ ); +} + +function SubjectLink({ + category: {name, subjectIcon}, + from +}: { + category: Category; + from: string; +}) { + return ( +
+ +
+ {subjectIcon && } +
+
+ {name} +
+ +
+ ); +} diff --git a/src/app/components/explore-by-subject/types.ts b/src/app/components/explore-by-subject/types.ts new file mode 100644 index 000000000..0a060e969 --- /dev/null +++ b/src/app/components/explore-by-subject/types.ts @@ -0,0 +1,5 @@ +export type Category = { + id: string; + name: string; + subjectIcon?: string; +}; diff --git a/src/app/pages/blog/section-header/section-header.scss b/src/app/components/explore-page/section/section.scss similarity index 100% rename from src/app/pages/blog/section-header/section-header.scss rename to src/app/components/explore-page/section/section.scss diff --git a/src/app/components/explore-page/section/section.tsx b/src/app/components/explore-page/section/section.tsx new file mode 100644 index 000000000..1c56cbc8d --- /dev/null +++ b/src/app/components/explore-page/section/section.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import './section.scss'; + +type SectionArgs = React.PropsWithChildren<{ + name: string; + topicHeading?: string; +}> & React.HTMLAttributes; + +export default function Section({name, topicHeading, children, ...divAttributes}: SectionArgs) { + return ( +
+ + {children} +
+ ); +} + +type SectionHeaderArgs = { + head: string; + subhead?: string; +} + +function SectionHeader({head, subhead}: SectionHeaderArgs) { + return ( +

+ {head} + {subhead && {subhead}} +

+ ); +} diff --git a/src/app/components/jsx-helpers/build-context.js b/src/app/components/jsx-helpers/build-context.js index c4e3c135d..fb79045cf 100644 --- a/src/app/components/jsx-helpers/build-context.js +++ b/src/app/components/jsx-helpers/build-context.js @@ -10,10 +10,10 @@ export default function buildContext({ return React.useContext(Context); } - function ContextProvider({children, contextValueParameters}) { + function ContextProvider({children, contextValueParameters=undefined}) { const value = useContextValue(contextValueParameters); - if (typeof value === 'undefined') { + if (value === undefined) { return null; } diff --git a/src/app/components/jsx-helpers/loader-page.js b/src/app/components/jsx-helpers/loader-page.js index f18f93639..0e9d75f8c 100644 --- a/src/app/components/jsx-helpers/loader-page.js +++ b/src/app/components/jsx-helpers/loader-page.js @@ -30,7 +30,7 @@ function LoadedPage({ } export default function LoaderPage({ - slug, Child, props={}, preserveWrapping, doDocumentSetup=false, + slug, Child, props={}, preserveWrapping=false, doDocumentSetup=false, noCamelCase=false }) { const data = usePageData(slug, preserveWrapping, noCamelCase); diff --git a/src/app/components/link-with-chevron/link-with-chevron.js b/src/app/components/link-with-chevron/link-with-chevron.js index a9f57455b..e404128b2 100644 --- a/src/app/components/link-with-chevron/link-with-chevron.js +++ b/src/app/components/link-with-chevron/link-with-chevron.js @@ -4,7 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'; import './link-with-chevron.scss'; -export default function LinkWithChevron({children, className, ...props}) { +export default function LinkWithChevron({children, className=undefined, ...props}) { return ( {children}{' '} diff --git a/src/app/components/paginator/simple-paginator.js b/src/app/components/paginator/simple-paginator.js index cc07969ff..742418a29 100644 --- a/src/app/components/paginator/simple-paginator.js +++ b/src/app/components/paginator/simple-paginator.js @@ -96,3 +96,21 @@ export default function SimplePaginator({currentPage, setPage, totalPages}) { ); } + +export function itemRangeOnPage(page, perPage, totalCount) { + const end = Math.min(totalCount, page * perPage); + const start = (page - 1) * perPage + 1; + + return [start, end]; +} + +export function Showing({page, perPage=9, totalCount, ofWhat}) { + const [start, end] = itemRangeOnPage(page, perPage, totalCount); + const countMessage = totalCount <= perPage ? 'all' : `${start}-${end} of`; + + return ( +
+ Showing {countMessage} {totalCount} {ofWhat} +
+ ); +} diff --git a/src/app/pages/blog/search-bar/search-bar.js b/src/app/components/search-bar/search-bar.js similarity index 84% rename from src/app/pages/blog/search-bar/search-bar.js rename to src/app/components/search-bar/search-bar.js index 0fbd40357..36f31ab46 100644 --- a/src/app/pages/blog/search-bar/search-bar.js +++ b/src/app/components/search-bar/search-bar.js @@ -1,12 +1,11 @@ import React from 'react'; import useSearchContext, {SearchContextProvider} from './search-context'; -import useBlogContext from '../blog-context'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {faTimes} from '@fortawesome/free-solid-svg-icons/faTimes'; import {faSearch} from '@fortawesome/free-solid-svg-icons/faSearch'; import './search-bar.scss'; -function SearchInput() { +function SearchInput({amongWhat}) { const {searchString, setSearchString, doSearch} = useSearchContext(); const onChange = React.useCallback( (event) => { @@ -26,7 +25,7 @@ function SearchInput() { return ( ); @@ -73,12 +72,12 @@ function SearchButton() { ); } -export default function SearchBar() { +export default function SearchBar({searchFor, amongWhat}) { return ( - +
- +
@@ -87,13 +86,11 @@ export default function SearchBar() { ); } -export function HeadingAndSearchBar({children}) { - const {setPath} = useBlogContext(); - +export function HeadingAndSearchBar({searchFor, amongWhat, children}) { return (
{children} - +
); } diff --git a/src/app/components/search-bar/search-bar.scss b/src/app/components/search-bar/search-bar.scss new file mode 100644 index 000000000..78a1ec91e --- /dev/null +++ b/src/app/components/search-bar/search-bar.scss @@ -0,0 +1,80 @@ +@import 'pattern-library/core/pattern-library/headers'; + +.search-bar { + display: grid; + grid-column-gap: 1rem; + grid-row-gap: 1rem; + grid-template: 'input button' / 1fr min-content; + justify-items: left; + + @include width-up-to($phone-max) { + width: 100%; + } + + @include wider-than($phone-max) { + width: 50%; + } + + .input-with-clear-button { + align-items: center; + display: grid; + grid-area: input; + grid-template-columns: 1fr; + grid-template-rows: 1fr; + width: 100%; + + input { + @extend %non-button-input; + + grid-column: 1; + grid-row: 1; + height: 100%; + } + + @include wider-than($phone-max) { + min-width: 40rem; + } + + .clear-search { + grid-column: 1; + grid-row: 1; + justify-self: end; + margin-right: 1rem; + padding: 0 0.5rem; + + &[hidden] { + display: none; + } + } + } + + button { + @extend %button; + + font-size: 2rem; + grid-area: button; + padding: 0; + width: 7.5rem; + + &.primary { + @extend %primary; + } + } +} + +.heading-and-searchbar { + display: flex; + max-width: $content-max; + width: 100%; + + @include width-up-to($tablet-max) { + align-items: flex-start; + flex-direction: column; + row-gap: 2rem; + } + + @include wider-than($tablet-max) { + align-items: center; + justify-content: space-between; + } +} diff --git a/src/app/components/search-bar/search-context.ts b/src/app/components/search-bar/search-context.ts new file mode 100644 index 000000000..1358b9e8b --- /dev/null +++ b/src/app/components/search-bar/search-context.ts @@ -0,0 +1,27 @@ +import {useState, useCallback} from 'react'; +import {useLocation} from 'react-router-dom'; +import buildContext from '~/components/jsx-helpers/build-context'; + +export function useCurrentSearchParameter() { + const {search} = useLocation(); + + return new window.URLSearchParams(search).get('q') ?? ''; +} + +type SearchFunction = (term: string) => Array; + +function useContextValue({searchFor}: {searchFor: SearchFunction}) { + const [searchString, setSearchString] = useState( + useCurrentSearchParameter() + ); + const doSearch = useCallback( + () => searchFor(searchString), + [searchFor, searchString] + ); + + return {searchString, setSearchString, doSearch}; +} + +const {useContext, ContextProvider} = buildContext({useContextValue}); + +export {useContext as default, ContextProvider as SearchContextProvider}; diff --git a/src/app/components/shell/router.js b/src/app/components/shell/router.js index 49f10315d..9ccbbad43 100644 --- a/src/app/components/shell/router.js +++ b/src/app/components/shell/router.js @@ -196,6 +196,7 @@ function MainRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/app/helpers/page-data-utils.js b/src/app/helpers/page-data-utils.js index 2be3b00d4..b320a34ee 100644 --- a/src/app/helpers/page-data-utils.js +++ b/src/app/helpers/page-data-utils.js @@ -27,7 +27,7 @@ export function transformData(data) { return data; } -async function getUrlFor(initialSlug) { +export async function getUrlFor(initialSlug) { let apiUrl = urlFromSlug(initialSlug); // A little magic to handle book titles diff --git a/src/app/helpers/use-data.ts b/src/app/helpers/use-data.ts new file mode 100644 index 000000000..4855a2b41 --- /dev/null +++ b/src/app/helpers/use-data.ts @@ -0,0 +1,88 @@ +// Universal data fetcher/processor to replace the various data-fetching hooks, I hope +import React from 'react'; +import {getUrlFor, camelCaseKeys, transformData} from './page-data-utils'; + +// Any Promise returned by fetch, or a CMS endpoint reference +// we can use to make such a Promise +type UrlSource = {url: string}; +type SlugSource = {slug: string}; +type PromiseSource = {promise: Promise}; +type SourceOption = UrlSource | SlugSource | PromiseSource; +// Fetched data has a resolveTo step, to extract json or text +// Others are available but not currently used +// https://developer.mozilla.org/en-US/docs/Web/API/Response#instance_methods +type ResponseMethod = 'json' | 'text'; +type ProcessingOptions = { + resolveTo: ResponseMethod; + // Common transformations of CMS data + transform?: boolean; + camelCase?: boolean; + postProcess?: (rawData: unknown) => unknown; +}; + +export default function useFetchedData( + options: SourceOption & ProcessingOptions, + defaultValue: T +): T { + const slug = (options as SlugSource).slug; + const slugPromise = React.useMemo(() => { + if (!slug) { + return null; + } + return new Promise((resolve) => + getUrlFor(slug).then((url: string) => + fetch(url).then(resolve) + ) + ); + }, [slug]); + const url = (options as UrlSource).url; + const urlPromise = React.useMemo(() => { + if (!url) { + return null; + } + return fetch(url); + }, [url]); + const promiseOption: Promise | null = (options as PromiseSource) + .promise; + const promise: Promise = React.useMemo( + () => slugPromise ?? urlPromise ?? promiseOption, + [slugPromise, urlPromise, promiseOption] + ); + const processedPromise = React.useMemo( + () => + promise + .then((resp) => resp[options.resolveTo]()) + .then((rawData) => processRawData(rawData, options)), + [promise, options.resolveTo] // eslint-disable-line react-hooks/exhaustive-deps + ); + + return usePromise(processedPromise, defaultValue); +} + +// This is a common pattern. Should be used throughout the codebase +export function usePromise(promise: Promise, defaultValue: T) { + const [data, setData] = React.useState(defaultValue); + + React.useEffect(() => { + promise.then(setData); + }, [promise]); + + return data; +} + +function processRawData(rawData: unknown, processing: ProcessingOptions): T { + let returnValue = rawData; + + if (processing.transform) { + // mutates + transformData(rawData); + } + if (processing.camelCase) { + returnValue = camelCaseKeys(rawData); + } + if (processing.postProcess) { + returnValue = (returnValue as Array).map(processing.postProcess); + } + + return returnValue as T; +} diff --git a/src/app/helpers/use-document-head.js b/src/app/helpers/use-document-head.js index 5d1f49e7d..ce296e9aa 100644 --- a/src/app/helpers/use-document-head.js +++ b/src/app/helpers/use-document-head.js @@ -113,7 +113,7 @@ export function setPageTitleAndDescriptionFromBookData(data={}) { ); } -export default function useDocumentHead({title, description, noindex}) { +export default function useDocumentHead({title, description=undefined, noindex=false}) { useEffect( () => setPageTitleAndDescription(title, description), [title, description] diff --git a/src/app/pages/blog/blog-context.js b/src/app/pages/blog/blog-context.js index a8869cdb8..8a10a9260 100644 --- a/src/app/pages/blog/blog-context.js +++ b/src/app/pages/blog/blog-context.js @@ -4,33 +4,23 @@ import usePageData from '~/helpers/use-page-data'; import {useDataFromSlug, camelCaseKeys} from '~/helpers/page-data-utils'; import buildContext from '~/components/jsx-helpers/build-context'; import useLatestBlogEntries from '~/models/blog-entries'; -import cmsFetch from '~/helpers/cms-fetch'; +import useData from '~/helpers/use-data'; const stayHere = {path: '/blog'}; function useEnglishSubjects() { - const [data, setData] = React.useState([]); - - React.useEffect( - () => cmsFetch('snippets/subjects?format=json&locale=en') - .then(camelCaseKeys) - .then(setData), - [] - ); - - return data; + return useData({ + slug: 'snippets/subjects?format=json&locale=en', + resolveTo: 'json', + camelCase: true + }, []); } function useCollections() { - const [data, setData] = React.useState([]); - - React.useEffect( - () => cmsFetch('snippets/blogcollection?format=json') - .then(camelCaseKeys) - .then(setData), - [] - ); - - return data; + return useData({ + slug: 'snippets/blogcollection?format=json', + resolveTo: 'json', + camelCase: true + }, []); } function useTopicStories() { @@ -93,6 +83,10 @@ function useContextValue({displayFooter, footerText, footerButtonText, footerLin }, [navigate] ); + const searchFor = React.useCallback( + (searchString) => setPath(`/blog/?q=${searchString}`), + [setPath] + ); if (pinnedStory && !pinnedStory.slug) { pinnedStory.slug = pinnedStory.meta.slug; @@ -102,21 +96,22 @@ function useContextValue({displayFooter, footerText, footerButtonText, footerLin setPath, pinnedStory, totalCount, subjectSnippet, collectionSnippet, topic, setTypeAndTopic, topicStories, topicFeatured, topicPopular, pageDescription: meta.searchDescription, - displayFooter, footerText, footerButtonText, footerLink + displayFooter, footerText, footerButtonText, footerLink, + searchFor }; } const {useContext, ContextProvider} = buildContext({useContextValue}); function BlogContextProvider({children}) { - const data = usePageData('news', false, true); + const data = usePageData('news'); if (!data) { return null; } return ( - + {children} ); diff --git a/src/app/pages/blog/blog.js b/src/app/pages/blog/blog.js index d327ed60a..e4844737c 100644 --- a/src/app/pages/blog/blog.js +++ b/src/app/pages/blog/blog.js @@ -3,13 +3,13 @@ import useBlogContext, {BlogContextProvider} from './blog-context'; import {Routes, Route, useLocation, useParams} from 'react-router-dom'; import {WindowContextProvider} from '~/contexts/window'; import useDocumentHead from '~/helpers/use-document-head'; -import ExploreBySubject from './explore/by-subject'; -import ExploreCollections from './explore/collections'; +import ExploreBySubject from '~/components/explore-by-subject/explore-by-subject'; +import ExploreByCollection from '~/components/explore-by-collection/explore-by-collection'; import ExplorePage from './explore-page/explore-page'; import PinnedArticle from './pinned-article/pinned-article'; import DisqusForm from './disqus-form/disqus-form'; import MoreStories from './more-stories/more-stories'; -import SearchBar, {HeadingAndSearchBar} from './search-bar/search-bar'; +import SearchBar, {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; import SearchResults from './search-results/search-results'; import LatestBlogPosts from './latest-blog-posts/latest-blog-posts'; import {ArticleFromSlug} from './article/article'; @@ -18,7 +18,7 @@ import StickyFooter from '../../components/sticky-footer/sticky-footer'; import './blog.scss'; export function SearchResultsPage() { - const {pageDescription} = useBlogContext(); + const {pageDescription, searchFor} = useBlogContext(); useDocumentHead({ title: 'OpenStax Blog Search', @@ -28,7 +28,7 @@ export function SearchResultsPage() { return (
- +
@@ -38,7 +38,12 @@ export function SearchResultsPage() { // Exported so it can be tested // eslint-disable-next-line complexity export function MainBlogPage() { - const {pinnedStory, pageDescription, displayFooter, footerText, footerButtonText, footerLink} = useBlogContext(); + const { + pinnedStory, pageDescription, searchFor, + subjectSnippet: categories, + collectionSnippet: collections, + displayFooter, footerText, footerButtonText, footerLink + } = useBlogContext(); const leftButton = { descriptionHtml: footerText || 'Interested in sharing your story?', text: footerButtonText || 'Write for us', @@ -53,11 +58,11 @@ export function MainBlogPage() { return (
- +

OpenStax Blog

- - + +
diff --git a/src/app/pages/blog/blog.scss b/src/app/pages/blog/blog.scss index 531b8f94f..a41cf0563 100644 --- a/src/app/pages/blog/blog.scss +++ b/src/app/pages/blog/blog.scss @@ -6,14 +6,7 @@ justify-items: center; width: 100%; - .breadcrumb { - align-items: center; - font-weight: bold; - display: flex; - gap: 1rem; - margin-bottom: 2rem; - margin-top: 3rem; - text-decoration: none; + section { width: 100%; } @@ -28,6 +21,12 @@ } } + .explore-topic-blurb { + align-items: start; + margin: 0; + padding: 0; + } + .boxed.left { align-items: flex-start; margin: 3rem auto; @@ -58,8 +57,6 @@ h2 { &:not(.section-header) { @include set-font(h3); - - margin: 0; } } diff --git a/src/app/pages/blog/explore-page/explore-page.js b/src/app/pages/blog/explore-page/explore-page.js index a8613adc3..b7d6dd108 100644 --- a/src/app/pages/blog/explore-page/explore-page.js +++ b/src/app/pages/blog/explore-page/explore-page.js @@ -1,14 +1,13 @@ import React from 'react'; import useBlogContext from '../blog-context'; -import {useParams, Link, useNavigate} from 'react-router-dom'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faChevronLeft} from '@fortawesome/free-solid-svg-icons/faChevronLeft'; +import {useParams} from 'react-router-dom'; +import Breadcrumb from '~/components/breadcrumb/breadcrumb'; import {WindowContextProvider} from '~/contexts/window'; import useDocumentHead from '~/helpers/use-document-head'; import PinnedArticle from '../pinned-article/pinned-article'; -import {HeadingAndSearchBar} from '../search-bar/search-bar'; +import {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; import MoreStories from '../more-stories/more-stories'; -import SectionHeader from '../section-header/section-header'; +import Section from '~/components/explore-page/section/section'; import ArticleSummary, {blurbModel} from '../article-summary/article-summary'; // If it returns null, the topic is not a Subject @@ -51,39 +50,27 @@ function useParamsToSetTopic() { export default function ExplorePage() { useParamsToSetTopic(); - const {topic, pinnedStory, topicPopular, setPath, pageDescription} = useBlogContext(); - const navigate = useNavigate(); - const goBack = React.useCallback( - (e) => { - navigate(-1); - e.preventDefault(); - }, - [navigate] - ); + const {topic, pinnedStory, topicPopular, setPath, searchDescription, searchFor} = useBlogContext(); const subject = useSubjectSnippetForTopic(topic); const heading = useTopicHeading(topic, subject); useDocumentHead({ title: `${topic} blog posts - OpenStax`, - description: pageDescription + description: searchDescription }); return (
- - - Back to Main Blog - - + +
{subject?.pageContent}
-
- +
-
+
diff --git a/src/app/pages/blog/explore/by-subject.js b/src/app/pages/blog/explore/by-subject.js deleted file mode 100644 index 8b8226958..000000000 --- a/src/app/pages/blog/explore/by-subject.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import useBlogContext from '../blog-context'; -import {Link} from 'react-router-dom'; -import './by-subject.scss'; - -function SubjectLink({data}) { - return ( -
-
- {data.subjectIcon && } -
-
- {data.name} -
-
- ); -} - -export default function ExploreBySubject() { - const {subjectSnippet: categories} = useBlogContext(); - - return ( -
-

Explore by subject

-
- { - categories.map( - (c) => - ) - } -
-
- ); -} diff --git a/src/app/pages/blog/explore/collections.js b/src/app/pages/blog/explore/collections.js deleted file mode 100644 index c65288699..000000000 --- a/src/app/pages/blog/explore/collections.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import useBlogContext from '../blog-context'; -import {Link} from 'react-router-dom'; -import './collections.scss'; - -function CollectionLink({data}) { - return ( - -
- -
-
{data.name}
- - ); -} - -export default function ExploreCollections() { - const {collectionSnippet: collections} = useBlogContext(); - - return ( -
-

Explore collections

-
- { - collections.map( - (c) => - ) - } -
-
- ); -} diff --git a/src/app/pages/blog/latest-blog-posts/latest-blog-posts.js b/src/app/pages/blog/latest-blog-posts/latest-blog-posts.js index dd0cb70bc..5dc5d3aec 100644 --- a/src/app/pages/blog/latest-blog-posts/latest-blog-posts.js +++ b/src/app/pages/blog/latest-blog-posts/latest-blog-posts.js @@ -1,26 +1,13 @@ import React from 'react'; import {LatestBlurbs} from '../more-stories/more-stories'; -import {HeadingAndSearchBar} from '../search-bar/search-bar'; -import SimplePaginator from '~/components/paginator/simple-paginator'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faChevronLeft} from '@fortawesome/free-solid-svg-icons/faChevronLeft'; +import {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; +import SimplePaginator, {Showing} from '~/components/paginator/simple-paginator'; +import Breadcrumb from '~/components/breadcrumb/breadcrumb'; import useBlogContext from '../blog-context'; import './latest-blog-posts.scss'; const perPage = 9; -function Showing({page}) { - const {totalCount} = useBlogContext(); - const end = Math.min(totalCount, page * perPage); - const start = end - perPage + 1; - - return ( -
- Showing {start}-{end} of all {totalCount} blog posts -
- ); -} - export default function LatestBlogPosts() { const [page, setPage] = React.useState(1); @@ -29,7 +16,7 @@ export default function LatestBlogPosts() { [page] ); - const {totalCount} = useBlogContext(); + const {totalCount, searchFor} = useBlogContext(); if (!totalCount) { return null; @@ -40,14 +27,11 @@ export default function LatestBlogPosts() { return (
- - - Back to Main Blog - - + +

Latest blog posts

- +
diff --git a/src/app/pages/blog/latest-blog-posts/latest-blog-posts.scss b/src/app/pages/blog/latest-blog-posts/latest-blog-posts.scss index 8a8d68f4c..dd0779b7f 100644 --- a/src/app/pages/blog/latest-blog-posts/latest-blog-posts.scss +++ b/src/app/pages/blog/latest-blog-posts/latest-blog-posts.scss @@ -16,14 +16,6 @@ padding-top: 3rem; } - .breadcrumb { - align-items: center; - font-weight: bold; - display: flex; - gap: 1rem; - text-decoration: none; - } - .whats-showing { @include title-font(1.8rem); } diff --git a/src/app/pages/blog/more-stories/more-stories.js b/src/app/pages/blog/more-stories/more-stories.js index 7bf59b982..31735b591 100644 --- a/src/app/pages/blog/more-stories/more-stories.js +++ b/src/app/pages/blog/more-stories/more-stories.js @@ -3,7 +3,7 @@ import ArticleSummary, {blurbModel} from '../article-summary/article-summary'; import useLatestBlogEntries from '~/models/blog-entries'; import useBlogContext from '../blog-context'; import './more-stories.scss'; -import SectionHeader from '../section-header/section-header'; +import Section from '~/components/explore-page/section/section'; export function LatestBlurbs({page, pageSize, exceptSlug, openInNewWindow}) { const numberNeeded = page * pageSize; @@ -37,8 +37,7 @@ export function LatestBlurbs({page, pageSize, exceptSlug, openInNewWindow}) { export default function MoreStories({exceptSlug, subhead}) { return ( - + ); } diff --git a/src/app/pages/blog/pinned-article/pinned-article.js b/src/app/pages/blog/pinned-article/pinned-article.js index af034a077..0e0e7c6c3 100644 --- a/src/app/pages/blog/pinned-article/pinned-article.js +++ b/src/app/pages/blog/pinned-article/pinned-article.js @@ -1,7 +1,7 @@ import React from 'react'; import ArticleSummary, {blurbModel} from '../article-summary/article-summary'; import useBlogContext from '../blog-context'; -import SectionHeader from '../section-header/section-header'; +import Section from '~/components/explore-page/section/section'; import './pinned-article.scss'; export default function PinnedArticle({subhead}) { @@ -13,14 +13,13 @@ export default function PinnedArticle({subhead}) { const model = {...blurbModel(pinnedStory), setPath}; return ( - - +
- +
); } diff --git a/src/app/pages/blog/search-bar/search-bar.scss b/src/app/pages/blog/search-bar/search-bar.scss deleted file mode 100644 index e48cc8cef..000000000 --- a/src/app/pages/blog/search-bar/search-bar.scss +++ /dev/null @@ -1,81 +0,0 @@ -@import 'pattern-library/core/pattern-library/headers'; - -.blog.page { - .search-bar { - display: grid; - grid-column-gap: 1rem; - grid-row-gap: 1rem; - grid-template: 'input button' / 1fr min-content; - justify-items: left; - - @include width-up-to($phone-max) { - width: 100%; - } - - @include wider-than($phone-max) { - width: 50%; - } - - .input-with-clear-button { - align-items: center; - display: grid; - grid-area: input; - grid-template-columns: 1fr; - grid-template-rows: 1fr; - width: 100%; - - input { - @extend %non-button-input; - - grid-column: 1; - grid-row: 1; - height: 100%; - } - - @include wider-than($phone-max) { - min-width: 40rem; - } - - .clear-search { - grid-column: 1; - grid-row: 1; - justify-self: end; - margin-right: 1rem; - padding: 0 0.5rem; - - &[hidden] { - display: none; - } - } - } - - button { - @extend %button; - - grid-area: button; - padding: 0; - width: 7.5rem; - - &.primary { - @extend %primary; - } - } - } - - .heading-and-searchbar { - display: flex; - max-width: $content-max; - width: 100%; - - @include width-up-to($tablet-max) { - align-items: flex-start; - flex-direction: column; - row-gap: 2rem; - } - - @include wider-than($tablet-max) { - align-items: center; - justify-content: space-between; - } - } -} diff --git a/src/app/pages/blog/search-bar/search-context.js b/src/app/pages/blog/search-bar/search-context.js deleted file mode 100644 index 27c857c60..000000000 --- a/src/app/pages/blog/search-bar/search-context.js +++ /dev/null @@ -1,21 +0,0 @@ -import {useState} from 'react'; -import buildContext from '~/components/jsx-helpers/build-context'; -import useBlogContext from '../blog-context'; - -function useContextValue() { - const {setPath} = useBlogContext(); - const [searchString, setSearchString] = useState(new window.URLSearchParams(window.location.search).get('q') || ''); - - function doSearch() { - setPath(`/blog/?q=${searchString}`); - } - - return {searchString, setSearchString, doSearch}; -} - -const {useContext, ContextProvider} = buildContext({useContextValue}); - -export { - useContext as default, - ContextProvider as SearchContextProvider -}; diff --git a/src/app/pages/blog/section-header/section-header.js b/src/app/pages/blog/section-header/section-header.js deleted file mode 100644 index 5f790d82a..000000000 --- a/src/app/pages/blog/section-header/section-header.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import './section-header.scss'; - -export default function SectionHeader({head, subhead}) { - return ( -

- {head} - {subhead && {subhead}} -

- ); -} diff --git a/src/app/pages/webinars/explore-page/explore-page.scss b/src/app/pages/webinars/explore-page/explore-page.scss new file mode 100644 index 000000000..625dfcfed --- /dev/null +++ b/src/app/pages/webinars/explore-page/explore-page.scss @@ -0,0 +1,9 @@ +.webinars.page .explore-page { + .heading-and-searchbar { + h1 { + display: flex; + align-items: center; + gap: 1rem; + } + } +} diff --git a/src/app/pages/webinars/explore-page/explore-page.tsx b/src/app/pages/webinars/explore-page/explore-page.tsx new file mode 100644 index 000000000..365fe35a7 --- /dev/null +++ b/src/app/pages/webinars/explore-page/explore-page.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import useWebinarContext from '../webinar-context'; +import useDocumentHead from '~/helpers/use-document-head'; +import Breadcrumb from '~/components/breadcrumb/breadcrumb'; +import HeadingAndSearchBar from './heading-and-search-bar'; +import Section from '~/components/explore-page/section/section'; +import {useParams} from 'react-router-dom'; +import {WebinarGrid} from '../webinar-cards/latest-webinars'; +import './explore-page.scss'; + +type ExploreType = 'subjects' | 'collections'; +type SectionKey = 'popular' | 'featured'; + +export default function ExplorePage() { + const {exploreType, topic} = useParams(); + const topicHeading = `OpenStax ${topic} webinars`; + const featuredWebinars = useFilteredWebinars( + exploreType as ExploreType, + topic, + 'featured' + ); + const popularWebinars = useFilteredWebinars( + exploreType as ExploreType, + topic, + 'popular' + ); + const latestWebinars = useFilteredWebinars( + exploreType as ExploreType, + topic + ); + + useDocumentHead({ + title: `${topic} Webinars` + }); + + return ( +
+ + + {featuredWebinars.length > 0 && ( +
+ +
+ )} + {popularWebinars.length > 0 && ( +
+ +
+ )} +
+ +
+
+ ); +} + +function useFilteredWebinars( + exploreType?: ExploreType, + topic?: string, + sectionKey?: SectionKey +) { + const {latestWebinars} = useWebinarContext(); + + return React.useMemo( + () => + latestWebinars.filter((w) => { + if (exploreType === 'subjects') { + const associations = w.subjects; + const hasAssociation = associations.some( + (a) => + a.subject === topic && + (!sectionKey || + (sectionKey === 'featured' && + a[sectionKey] === 'True')) + ); + + return hasAssociation; + } + if (exploreType === 'collections') { + const associations = w.collections; + const hasAssociation = associations.some( + (a) => + a.collection === topic && + (!sectionKey || a[sectionKey] === 'True') + ); + + return hasAssociation; + } + return false; + }), + [exploreType, topic, sectionKey, latestWebinars] + ); +} diff --git a/src/app/pages/webinars/explore-page/heading-and-search-bar.tsx b/src/app/pages/webinars/explore-page/heading-and-search-bar.tsx new file mode 100644 index 000000000..d9a5f9efb --- /dev/null +++ b/src/app/pages/webinars/explore-page/heading-and-search-bar.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; +import useWebinarContext from '../webinar-context'; +import {Category} from '~/components/explore-by-subject/types'; + +export default function WebinarHSB({topic}: {topic: string}) { + const {searchFor, subjects} = useWebinarContext(); + const subject = subjects.find((s) => s.name === topic); + const heading = subject ? `OpenStax ${topic} Textbooks` : topic; + + return ( + + + + ); +} + +type HeadingArgs = { + subject: Category | undefined; + heading: string; +}; + +function HeadingForExplorePage({subject, heading}: HeadingArgs) { + return ( +

+ {subject?.subjectIcon && } + {heading} +

+ ); +} diff --git a/src/app/pages/webinars/import-explore-page.js b/src/app/pages/webinars/import-explore-page.js new file mode 100644 index 000000000..8b7bd6d56 --- /dev/null +++ b/src/app/pages/webinars/import-explore-page.js @@ -0,0 +1,4 @@ +// Dynamic imports must be JS, not TS +import ExplorePage from './explore-page/explore-page'; + +export default ExplorePage; diff --git a/src/app/pages/webinars/import-latest-page.js b/src/app/pages/webinars/import-latest-page.js new file mode 100644 index 000000000..8c8477d24 --- /dev/null +++ b/src/app/pages/webinars/import-latest-page.js @@ -0,0 +1,4 @@ +// Dynamic imports must be JS, not TS +import LatestPage from './latest-page/latest-page'; + +export default LatestPage; diff --git a/src/app/pages/webinars/import-main-page.js b/src/app/pages/webinars/import-main-page.js new file mode 100644 index 000000000..9d07f3974 --- /dev/null +++ b/src/app/pages/webinars/import-main-page.js @@ -0,0 +1,4 @@ +// Dynamic imports must be JS, not TS +import MainPage from './main-page/main-page'; + +export default MainPage; diff --git a/src/app/pages/webinars/import-search-page.js b/src/app/pages/webinars/import-search-page.js new file mode 100644 index 000000000..9ff205846 --- /dev/null +++ b/src/app/pages/webinars/import-search-page.js @@ -0,0 +1,4 @@ +// Dynamic imports must be JS, not TS +import SearchPage from './search-page/search-page'; + +export default SearchPage; diff --git a/src/app/pages/webinars/latest-page/latest-page.tsx b/src/app/pages/webinars/latest-page/latest-page.tsx new file mode 100644 index 000000000..185a7b520 --- /dev/null +++ b/src/app/pages/webinars/latest-page/latest-page.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Breadcrumb from '~/components/breadcrumb/breadcrumb'; +import {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; +import SimplePaginator, { + itemRangeOnPage, + Showing +} from '~/components/paginator/simple-paginator'; +import useWebinarContext from '../webinar-context'; +import {WebinarGrid} from '../webinar-cards/latest-webinars'; + +const perPage = 9; + +export default function LatestWebinarsPage() { + const [page, setPage] = React.useState(1); + const {latestWebinars} = useWebinarContext(); + const totalCount = latestWebinars.length; + const totalPages = Math.ceil(totalCount / perPage); + const [first, last] = itemRangeOnPage(page, perPage, totalCount); + + return ( +
+ + + + + +
+ ); +} + +function WebinarHSB() { + const {searchFor} = useWebinarContext(); + + return ( + +

Webinars

+
+ ); +} diff --git a/src/app/pages/webinars/main-page/main-page.tsx b/src/app/pages/webinars/main-page/main-page.tsx new file mode 100644 index 000000000..01c47fe45 --- /dev/null +++ b/src/app/pages/webinars/main-page/main-page.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import useDocumentHead from '~/helpers/use-document-head'; +import useWebinarContext from '../webinar-context'; +import {HeadingAndSearchBar} from '../../../components/search-bar/search-bar'; +import ExploreBySubject from '~/components/explore-by-subject/explore-by-subject'; +import ExploreByCollection from '~/components/explore-by-collection/explore-by-collection'; +import LatestWebinars from '../webinar-cards/latest-webinars'; + +export default function MainPage() { + const {subjects, searchFor, pageData, collections} = useWebinarContext(); + + useDocumentHead({ + title: pageData.title + }); + + return ( +
+ +

{pageData.heading}

+
+ + + +
+ ); +} diff --git a/src/app/pages/webinars/search-page/search-page.tsx b/src/app/pages/webinars/search-page/search-page.tsx new file mode 100644 index 000000000..f19a9a43d --- /dev/null +++ b/src/app/pages/webinars/search-page/search-page.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {Webinar} from '../types'; +import Breadcrumb from '~/components/breadcrumb/breadcrumb'; +import SearchBar from '~/components/search-bar/search-bar'; +import useWebinarContext from '../webinar-context'; +import {useCurrentSearchParameter} from '~/components/search-bar/search-context'; +import useFetchedData from '~/helpers/use-data'; +import SimplePaginator, { + itemRangeOnPage, + Showing +} from '~/components/paginator/simple-paginator'; +import {WebinarGrid} from '../webinar-cards/latest-webinars'; + +const perPage = 9; + +export default function SearchPage() { + const {searchFor} = useWebinarContext(); + const webinars = useSearchResults(); + const [page, setPage] = React.useState(1); + + if (typeof webinars === 'undefined') { + return null; + } + const totalCount = webinars.length; + const totalPages = Math.ceil(totalCount / perPage); + const [first, last] = itemRangeOnPage(page, perPage, totalCount); + + return ( +
+ + + + + +
+ ); +} + +function useSearchResults() { + const term = useCurrentSearchParameter(); + + return useFetchedData( + { + slug: `webinars/search?q=${encodeURIComponent(term)}`, + resolveTo: 'json', + camelCase: true, + postProcess(wRaw) { + const w = wRaw as Webinar; + + w.start = new Date(w.start); + w.end = new Date(w.end); + return w; + } + }, + undefined + ); +} diff --git a/src/app/pages/webinars/types.ts b/src/app/pages/webinars/types.ts new file mode 100644 index 000000000..05a11caf3 --- /dev/null +++ b/src/app/pages/webinars/types.ts @@ -0,0 +1,31 @@ +export type PageData = { + title?: string; + heading?: string; +}; + +type BooleanString = 'True' | 'False'; + +type SubjectEntry = { + subject: string; + featured: BooleanString; +}; + +type CollectionEntry = { + collection: string; + featured: BooleanString; + popular: BooleanString; +}; + +export type Webinar = { + id: number; + subjects: SubjectEntry[]; + collections: CollectionEntry[]; + title: string; + description: string; + start: Date; + end: Date; + registrationLinkText: string; + registrationUrl: string; + speakers: string; + spacesRemaining: number; +}; diff --git a/src/app/pages/webinars/webinar-cards/latest-webinars.scss b/src/app/pages/webinars/webinar-cards/latest-webinars.scss new file mode 100644 index 000000000..c44b2ee64 --- /dev/null +++ b/src/app/pages/webinars/webinar-cards/latest-webinars.scss @@ -0,0 +1,94 @@ +@import 'pattern-library/core/pattern-library/headers'; + +.webinars.page { + .latest-webinars { + width: 100%; + + .button-row { + padding-top: 2rem; + } + + .btn.primary { + @include button(); + @extend %primary; + + margin-top: 3rem; + } + } + + .card-grid { + display: grid; + gap: 3rem; + grid-template-columns: repeat(auto-fit, 37rem); + } + + .card { + @extend %card; + + display: grid; + padding: 2rem 3rem; + + h3 { + @include set-font(h4); + } + + &:not(:hover) { + h3 { + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + } + } + + &.past { + gap: $normal-margin; + grid-template-rows: auto 1fr auto; + } + + &.upcoming { + gap: 3rem; + + h3 { + margin: 0; + } + + .dated-heading { + display: grid; + gap: 2rem; + grid-template-columns: auto 1fr; + + .date { + text-align: center; + } + + .day-of-month { + @include title-font(3.6rem); + } + + .month { + @include title-font(1.6rem); + + text-transform: uppercase; + } + } + + hr { + height: 0; + width: 100%; + border: thin solid ui-color(form-border); + } + + .speakers-and-spaces { + .label { + font-weight: bold; + } + } + } + + a { + color: text-color(link); + text-decoration: none; + } + } +} diff --git a/src/app/pages/webinars/webinar-cards/latest-webinars.tsx b/src/app/pages/webinars/webinar-cards/latest-webinars.tsx new file mode 100644 index 000000000..2e4c44e38 --- /dev/null +++ b/src/app/pages/webinars/webinar-cards/latest-webinars.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import {Webinar} from '../types'; +import useWebinarContext from '../webinar-context'; +import LinkWithChevron from '~/components/link-with-chevron/link-with-chevron'; +import './latest-webinars.scss'; + +type WebinarFilter = (w: Webinar) => boolean; +type LatestWebinarsArgs = { + filter?: WebinarFilter; + limit?: number; +}; + +export default function LatestWebinars({ + filter = () => true, + limit = 3 +}: LatestWebinarsArgs) { + const {latestWebinars} = useWebinarContext(); + const filteredWebinars = React.useMemo( + () => latestWebinars.filter(filter).slice(0, limit), + [latestWebinars, filter, limit] + ); + + return ( +
+

Latest webinars

+ + + View more of the latest + +
+ ); +} + +type WebinarGridArgs = { + webinars: Webinar[]; +}; +export function WebinarGrid({webinars}: WebinarGridArgs) { + return ( +
+ {webinars.map((w) => ( + + ))} +
+ ); +} + +function WebinarCard({data}: {data: Webinar}) { + if (data.start.valueOf() < Date.now()) { + return ; + } + return ; +} + +function PastWebinar({data}: {data: Webinar}) { + return ( +
+

{data.title}

+
{data.description}
+ + {data.registrationLinkText} + +
+ ); +} + +function UpcomingWebinar({data}: {data: Webinar}) { + const day = data.start.toLocaleString('en-us', {weekday: 'long'}); + const startTime = data.start.toLocaleString('en-us', { + hour: 'numeric', + minute: 'numeric' + }); + const endTime = data.end.toLocaleString('en-us', { + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short' + }); + + return ( +
+
+
+
{data.start.getDate()}
+
+ {data.start.toLocaleString('en', {month: 'short'})} +
+
+
+

{data.title}

+
+ {day}, {startTime} - {endTime} +
+
+
+
+
{data.description}
+
+
+ Speakers: + {data.speakers} +
+
+ Spaces remaining: + {data.spacesRemaining} +
+
+ + {data.registrationLinkText} + +
+ ); +} diff --git a/src/app/pages/webinars/webinar-context.tsx b/src/app/pages/webinars/webinar-context.tsx new file mode 100644 index 000000000..562b5aa42 --- /dev/null +++ b/src/app/pages/webinars/webinar-context.tsx @@ -0,0 +1,97 @@ +import React, {ReactElement} from 'react'; +import {useNavigate} from 'react-router-dom'; +import buildContext from '~/components/jsx-helpers/build-context'; +import useData from '~/helpers/use-data'; +import {Collection} from '~/components/explore-by-collection/types'; +import {Category} from '~/components/explore-by-subject/types'; +import {PageData, Webinar} from './types'; + +function useEnglishSubjects() { + return useData( + { + slug: 'snippets/subjects?format=json&locale=en', + resolveTo: 'json', + camelCase: true + }, + [] + ); +} + +function useCollections() { + return useData( + { + slug: 'snippets/webinarcollection?format=json', + resolveTo: 'json', + camelCase: true + }, + [] + ); +} + +function useWebinars() { + return useData( + { + slug: 'webinars/?format=json', + resolveTo: 'json', + camelCase: true, + postProcess: (wRaw) => { + const w = wRaw as Webinar; + + w.start = new Date(w.start); + w.end = new Date(w.end); + return w; + } + }, + [] + ); +} + +// Sort helper for webinars +function byDate(a: Webinar, b: Webinar) { + return b.start.valueOf() - a.start.valueOf(); +} + +function useContextValue() { + const subjects = useEnglishSubjects(); + const collections = useCollections(); + const webinars = useWebinars(); + const navigate = useNavigate(); + const searchFor = React.useCallback( + (term: string) => + navigate(`/webinars/search?q=${encodeURIComponent(term)}`), + [navigate] + ); + const pageData = useData( + { + slug: 'pages/webinars', + resolveTo: 'json', + camelCase: true + }, + {} + ); + const latestWebinars = React.useMemo( + () => webinars.sort(byDate), + [webinars] + ); + + return {subjects, searchFor, pageData, collections, latestWebinars}; +} + +// Until build-context is converted to TS +type Context = { + useContext: () => ReturnType; + ContextProvider: ReturnType['ContextProvider']; +}; +const {useContext, ContextProvider} = buildContext({ + useContextValue +}) as Context; + +function WebinarContextProvider({ + children +}: { + children: ReactElement | ReactElement[]; +}) { + return {children}; +} + +export {useContext as default, WebinarContextProvider}; diff --git a/src/app/pages/webinars/webinar-list/webinar-list.js b/src/app/pages/webinars/webinar-list/webinar-list.js deleted file mode 100644 index ffec769d4..000000000 --- a/src/app/pages/webinars/webinar-list/webinar-list.js +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import {faChevronRight} from '@fortawesome/free-solid-svg-icons/faChevronRight'; -import {useDataFromSlug} from '~/helpers/page-data-utils'; -import './webinar-list.scss'; - -function DatedHeading({entry}) { - const start = new Date(entry.start); - const month = start.toLocaleString('en-us', {month: 'short'}); - const weekday = start.toLocaleString('en-us', {weekday: 'long'}); - const startHour = start.toLocaleString('en-us', {hour: 'numeric', minute: 'numeric'}); - const endHour = new Date(entry.end) - .toLocaleString('en-us', {hour: 'numeric', minute: 'numeric', timeZoneName: 'short'}); - const dayAndTime = `${weekday}, ${startHour} - ${endHour}`; - - return ( - -
-
- {start.getDate()} - {month} -
-
-
-

{entry.title}

-
{entry.title}
-
- {dayAndTime} -
-
-
-
- ); -} - -function HoverTitle({entry}) { - return ( -
-

{entry.title}

-
{entry.title}
-
- ); -} - -function WebinarBox({entry, upcoming}) { - return ( -
- { - upcoming ? - : - - } -
{entry.description}
- { - upcoming && -
-
- Speakers: - {entry.speakers} -
-
- Spaces remaining: - {entry.spacesRemaining} -
-
- } - - {entry.registrationLinkText}{' '} - - -
- ); -} - -function NoWebinars() { - const snippet = useDataFromSlug('snippets/nowebinarmessage/?format=json'); - const message = snippet ? snippet[0].no_webinar_message : '(no webinars)'; - - return ( -
- {message} -
- ); -} - -export default function WebinarList({data, upcoming=false}) { - return ( -
- { - data.length ? - data.map((entry) => - - ) : - - } -
- ); -} diff --git a/src/app/pages/webinars/webinar-list/webinar-list.scss b/src/app/pages/webinars/webinar-list/webinar-list.scss deleted file mode 100644 index 1cd9cf084..000000000 --- a/src/app/pages/webinars/webinar-list/webinar-list.scss +++ /dev/null @@ -1,122 +0,0 @@ -@import 'pattern-library/core/pattern-library/headers'; -@import 'mixins/placeholder-selectors'; - -%spread-out-grid { - display: grid; - grid-gap: 3rem; -} - -%subgrid { - align-content: start; - display: grid; - grid-gap: 1rem; -} - -.webinar-list { - @extend %spread-out-grid; - padding-top: 3rem; - - @include width-up-to($phone-max) { - // grid-template-columns: 100%; - } - - @include wider-than($phone-max) { - grid-template-columns: repeat(auto-fit, 37rem); - } - - .card { - @extend %card; - @extend %spread-out-grid; - - background-color: ui-color(white); - grid-auto-rows: min-content; - padding: 2rem 3rem; - - h4 { - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - display: -webkit-box; - margin: 0; - overflow: hidden; - } - - .with-hovertitle { - position: relative; - - &:hover .hovertitle { - @extend %light-on-dark; - @include set-font(helper-label); - - background-color: os-color(gray); - border-radius: 0.3rem; - box-shadow: 0 0.1rem 0.1rem 0 rgba(ui-color(black), 0.5); - color: text-color(white); - content: attr(data-title); - left: 50%; - padding: 0.5rem; - position: absolute; - text-align: center; - transform: translateX(-50%); - width: 28rem; - } - - &:not(:hover) .hovertitle { - display: none; - } - } - - .dated-heading { - @extend %spread-out-grid; - - grid-template-columns: auto 1fr; - - .date, - .title-and-time { - @extend %subgrid; - } - - .date { - text-align: center; - } - - .day-of-month { - @include set-font(h2); - - margin: 0; - } - - .month { - @include title-font(1.6rem); - - text-transform: uppercase; - } - - .title-and-time { - .day-and-time { - color: text-color(helper); - } - } - } - - hr { - width: 100%; - border: thin solid ui-color(form-border); - } - - .description { - line-height: 3rem; - } - - .speakers-and-spaces { - @extend %subgrid; - } - - .label { - font-weight: bold; - } - - a { - text-decoration: none; - } - } -} diff --git a/src/app/pages/webinars/webinars.js b/src/app/pages/webinars/webinars.js deleted file mode 100644 index 9e7560f46..000000000 --- a/src/app/pages/webinars/webinars.js +++ /dev/null @@ -1,77 +0,0 @@ -import React, {useState} from 'react'; -import {useDataFromSlug, camelCaseKeys} from '~/helpers/page-data-utils'; -import LoaderPage from '~/components/jsx-helpers/loader-page'; -import ClippedImage from '~/components/clipped-image/clipped-image'; -import TabGroup from '~/components/tab-group/tab-group'; -import ContentGroup from '~/components/content-group/content-group'; -import AccordionGroup from '~/components/accordion-group/accordion-group.js'; -import WebinarList from './webinar-list/webinar-list'; -import './webinars.scss'; - -const tabLabels = ['Upcoming webinars', 'Past webinar recordings']; - -function byDate(a, b) { - const da = new Date(a.start).getTime(); - const db = new Date(b.start).getTime(); - - return da - db; -} - -function Webinars({data: {heading: headline, description, heroImage}}) { - const [selectedLabel, setSelectedLabel] = useState(tabLabels[0]); - const webinarData = camelCaseKeys( - (useDataFromSlug('webinars/?format=json') || []).sort(byDate) - ); - const firstFuture = webinarData.findIndex( - (entry) => new Date(entry.start).getTime() > Date.now() - ); - const index = firstFuture > 0 ? firstFuture : webinarData.length; - const upcomingData = webinarData.slice(index); - const pastData = webinarData.slice(0, index); - const tabContents = [ - {data: upcomingData, upcoming: true}, - {data: pastData} - ].map((props, i) => ); - const accordionItems = tabLabels.map((title, i) => ({ - title, - contentComponent: tabContents[i] - })); - - return ( - -
-
-

{headline}

-
{description}
-
- -
-
-
-

Webinars

-
- -
-
- - - {tabContents} - -
-
-
-
- ); -} - -export default function WebinarsLoader() { - return ( -
- -
- ); -} diff --git a/src/app/pages/webinars/webinars.scss b/src/app/pages/webinars/webinars.scss index 3bd628cbf..4812f0e77 100644 --- a/src/app/pages/webinars/webinars.scss +++ b/src/app/pages/webinars/webinars.scss @@ -1,105 +1,27 @@ @import 'pattern-library/core/pattern-library/headers'; -@import 'mixins/placeholder-selectors'; .webinars.page { - h1 { - @include set-font(h1); - } - - .hero { - @extend %light-on-dark; - @include set-font(body-large); - - background-color: text-color(link); - color: text-color(white); - padding: 0; - text-align: left; - - @include wider-than($phone-max) { - display: grid; - grid-template-columns: 1fr 1fr; - grid-template-rows: 100%; - } - - .text-side { - padding-left: $normal-margin; - - @include width-up-to($phone-max) { - padding: 4rem; - } - - @include wider-than($phone-max) { - margin-right: 23%; - padding-top: 6rem; - padding-bottom: 6rem; - } - - @include wider-than($media-content-max) { - padding-left: calc(50vw - #{$content-max / 2 - $normal-margin}); - } - } - - .picture-side { - background-repeat: no-repeat; - background-size: cover; - background-position: center center; - height: 100%; - - @include width-up-to($phone-max) { - height: 67vw; - } - } + background-color: ui-color(white); + justify-items: center; + width: 100%; - } + h1 { + @include set-font(h2); - .content { - @extend %content; + margin: 0; } - main { - padding: 6rem 0; - - @include width-up-to($phone-max) { - padding: 4rem 0; - } - - h2 { - @include set-font(h2); - - margin: 0 0 1rem; - } - - h3 { - @include body-font(1.8rem); - } - - .tab-group .tab { - padding: 1rem $normal-margin 1rem; - } + h2 { + @include set-font(h3); } - .phone-view { - @include wider-than($phone-max) { - display: none; - } - - .accordion-group { - margin-bottom: 1rem; - - .content-pane { - background-color: transparent; - margin-top: 1rem; - - .card { - margin-bottom: 2rem; - } - } - } + .boxed { + align-items: start; + gap: 3rem; + padding: 2.5rem $normal-margin; } - .bigger-view { - @include width-up-to($phone-max) { - display: none; - } + section { + width: 100%; } } diff --git a/src/app/pages/webinars/webinars.tsx b/src/app/pages/webinars/webinars.tsx new file mode 100644 index 000000000..45495d0a8 --- /dev/null +++ b/src/app/pages/webinars/webinars.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {WebinarContextProvider} from './webinar-context'; +import {Routes, Route} from 'react-router-dom'; +import JITLoad from '~/helpers/jit-load'; +import './webinars.scss'; + +const importMain = () => import('./import-main-page.js'); +const importExplore = () => import('./import-explore-page.js'); +const importLatest = () => import('./import-latest-page'); +const importSearch = () => import('./import-search-page'); + +export default function WebinarsLoader() { + return ( +
+ + + } + /> + } + /> + } + /> + } + /> + + +
+ ); +}