iT邦幫忙

2022 iThome 鐵人賽

DAY 25
0

今天我們看到課程的簡介頁

在課程簡介頁中
最上方為 Banner 區塊
他顯示課程封面圖、標題和標籤
在封面圖的部分做了放大和模糊
具體的實作方是如下:

import React from 'react'
import styled from 'styled-components'
import { BREAK_POINT } from './Responsive'

const StyledWrapper = styled.div<{ width?: { desktop: string; mobile: string } }>`
  position: relative;
  overflow: ${props => (props.width ? 'visible' : 'hidden')};
  height: ${props => (props.width ? props.width.mobile : 'auto')};
  @media (min-width: ${BREAK_POINT}px) {
    height: ${props => (props.width ? props.width.desktop : 'auto')};
  }
`
const BackgroundWrapper = styled.div`
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  transform: scale(1.1);
`
const BlurredCover = styled.div<{ coverUrl?: { mobileUrl?: string; desktopUrl?: string } }>`
  width: 100%;
  height: 100%;
  background-image: url(${props => props.coverUrl?.mobileUrl || props.coverUrl?.desktopUrl});
  background-size: cover;
  background-position: center;
  background-attachment: fixed;
  filter: blur(6px);
  @media (min-width: ${BREAK_POINT}px) {
    background-image: url(${props => props.coverUrl?.desktopUrl || props.coverUrl?.mobileUrl});
  }
`
const ContentWrapper = styled.div`
  position: relative;
  background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.6));
  height: 100%;
`

const BlurredBanner: React.FC<{
  coverUrl?: { mobileUrl?: string; desktopUrl?: string }
  width?: { desktop: string; mobile: string }
}> = ({ coverUrl, width, children }) => {
  return (
    <StyledWrapper width={width}>
      <BackgroundWrapper>
        <BlurredCover coverUrl={coverUrl} />
      </BackgroundWrapper>

      <ContentWrapper>{children}</ContentWrapper>
    </StyledWrapper>
  )
}

export default BlurredBanner

我們再來看中間的部分
左側會顯示課程的簡介和課程內容
在課程內容的部分會根據後台選擇的而顯示不同
分為四個狀態,「隱藏」、「試看」、「登入試看」和「付費觀看」
右側則會顯示課程資訊
當課程是有影片,則分鐘數會顯示課程影片加總的分鐘數

最下方為講師簡介和課程評價

課程簡介的程式碼如下

import { Box, Button, Icon, Spinner } from '@chakra-ui/react'
import { BraftContent } from 'lodestar-app-element/src/components/common/StyledBraftEditor'
import Tracking from 'lodestar-app-element/src/components/common/Tracking'
import CommonModal from 'lodestar-app-element/src/components/modals/CommonModal'
import { useApp } from 'lodestar-app-element/src/contexts/AppContext'
import { useAuth } from 'lodestar-app-element/src/contexts/AuthContext'
import { useResourceCollection } from 'lodestar-app-element/src/hooks/resource'
import queryString from 'query-string'
import React, { useContext, useEffect, useRef, useState } from 'react'
import ReactGA from 'react-ga'
import { defineMessage, useIntl } from 'react-intl'
import { Link, Redirect, useHistory, useLocation, useParams } from 'react-router-dom'
import styled, { css } from 'styled-components'
import { BooleanParam, StringParam, useQueryParam } from 'use-query-params'
import Responsive, { BREAK_POINT } from '../../components/common/Responsive'
import DefaultLayout from '../../components/layout/DefaultLayout'
import ReviewCollectionBlock from '../../components/review/ReviewCollectionBlock'
import PodcastPlayerContext from '../../contexts/PodcastPlayerContext'
import { desktopViewMixin, rgba } from '../../helpers'
import { commonMessages } from '../../helpers/translation'
import { useEnrolledProgramIds, useProgram } from '../../hooks/program'
import { useEnrolledProgramPackage } from '../../hooks/programPackage'
import { ReactComponent as PlayIcon } from '../../images/play-fill-icon.svg'
import ForbiddenPage from '../ForbiddenPage'
import { CustomizeProgramBanner, PerpetualProgramBanner } from './ProgramBanner'
import ProgramBestReviewsCarousel from './ProgramBestReviewsCarousel'
import ProgramContentListSection from './ProgramContentListSection'
import ProgramContentCountBlock from './ProgramInfoBlock/ProgramContentCountBlock'
import ProgramInfoCard, { StyledProgramInfoCard } from './ProgramInfoBlock/ProgramInfoCard'
import ProgramInstructorCollectionBlock from './ProgramInstructorCollectionBlock'
import ProgramPageHelmet from './ProgramPageHelmet'
import ProgramPlanCard from './ProgramPlanCard'

const StyledIntroWrapper = styled.div`
  ${desktopViewMixin(css`
    order: 1;
    padding-left: 35px;
  `)}
`
const ProgramAbstract = styled.span`
  padding-right: 2px;
  padding-bottom: 2px;
  background-image: linear-gradient(
    to bottom,
    transparent 40%,
    ${props => rgba(props.theme['@primary-color'], 0.1)} 40%
  );
  background-repeat: no-repeat;
  font-size: 20px;
  font-weight: bold;
  white-space: pre-line;
`
const ProgramIntroBlock = styled.div`
  position: relative;
  padding-top: 2.5rem;
  padding-bottom: 6rem;
  background: white;

  @media (min-width: ${BREAK_POINT}px) {
    padding-top: 3.5rem;
    padding-bottom: 1rem;
  }
`
const FixedBottomBlock = styled.div<{ bottomSpace?: string }>`
  margin: auto;
  position: fixed;
  width: 100%;
  bottom: ${props => props.bottomSpace || 0};
  left: 0;
  right: 0;
  z-index: 999;
`
const StyledButtonWrapper = styled.div`
  padding: 0.5rem 0.75rem;
  background: white;
`

const ProgramPage: React.VFC = () => {
  const { formatMessage } = useIntl()
  const { programId } = useParams<{ programId: string }>()
  const { pathname } = useLocation()
  const { currentMemberId } = useAuth()
  const { id: appId, settings, enabledModules } = useApp()
  const { resourceCollection } = useResourceCollection([`${appId}:program:${programId}`], true)
  const { visible } = useContext(PodcastPlayerContext)
  const { loadingProgram, program } = useProgram(programId)
  const enrolledProgramPackages = useEnrolledProgramPackage(currentMemberId || '', { programId })
  const planBlockRef = useRef<HTMLDivElement | null>(null)
  const customerReviewBlockRef = useRef<HTMLDivElement>(null)
  const location = useLocation()
  const [visitIntro] = useQueryParam('visitIntro', BooleanParam)
  const params = queryString.parse(location.search)
  const { loading: loadingEnrolledProgramIds, enrolledProgramIds } = useEnrolledProgramIds(currentMemberId || '')
  const isEnrolled = enrolledProgramIds.includes(programId)
  const [previousPage] = useQueryParam('back', StringParam)
  const [metaLoaded, setMetaLoaded] = useState<boolean>(false)

  useEffect(() => {
    if (customerReviewBlockRef.current && params.moveToBlock) {
      customerReviewBlockRef.current.scrollIntoView({ behavior: 'smooth' })
    }
  }, [customerReviewBlockRef, params])

  useEffect(() => {
    ReactGA.ga('send', 'pageview')
  }, [])

  if (loadingProgram || enrolledProgramPackages.loading || loadingEnrolledProgramIds) {
    return (
      <DefaultLayout>
        <Box className="d-flex justify-content-center align-items-center" h="100vh">
          <Spinner />
        </Box>
      </DefaultLayout>
    )
  }

  if (!program) {
    return <ForbiddenPage />
  }

  if (!visitIntro && isEnrolled) {
    return <Redirect to={`/programs/${programId}/contents?back=${previousPage}`} />
  }

  const instructorId = program.roles.filter(role => role.name === 'instructor').map(role => role.memberId)[0] || ''

  const isEnrolledByProgramPackage = !!enrolledProgramPackages.data.length

  const isDelivered = isEnrolledByProgramPackage
    ? enrolledProgramPackages.data.some(programPackage =>
        programPackage.enrolledPlans.some(plan => !plan.isTempoDelivery)
          ? true
          : programPackage.programs.some(program => program.id === programId && program.isDelivered),
      )
    : false

  return (
    <DefaultLayout white footerBottomSpace={program.plans.length > 1 ? '60px' : '132px'}>
      <ProgramPageHelmet program={program} onLoaded={() => setMetaLoaded(true)} />
      {resourceCollection[0] && metaLoaded && <Tracking.Detail resource={resourceCollection[0]} />}

      <div>
        {Number(settings['layout.program_page']) ? (
          <CustomizeProgramBanner program={program} isEnrolled={isEnrolled} />
        ) : (
          <PerpetualProgramBanner
            program={program}
            isEnrolledByProgramPackage={isEnrolledByProgramPackage}
            isDelivered={isDelivered}
          />
        )}

        <ProgramIntroBlock>
          <div className="container">
            <div className="row">
              <div className="col-12 col-lg-8">
                {!Number(settings['layout.program_page']) ? (
                  <Responsive.Default>
                    <StyledProgramInfoCard>
                      <ProgramContentCountBlock program={program} />
                    </StyledProgramInfoCard>
                  </Responsive.Default>
                ) : null}
                {!Number(settings['layout.program_page']) && program.abstract ? (
                  <div className="mb-5">
                    <ProgramAbstract>{program.abstract}</ProgramAbstract>
                  </div>
                ) : null}

                {Number(settings['layout.program_page']) ? (
                  <Responsive.Default>
                    <StyledIntroWrapper className="col-12 col-lg-4 mb-5 p-0">
                      {!!program.tags.length && (
                        <ProgramTagCard
                          tags={program.tags.map(tag => ({
                            id: tag,
                            name: tag,
                          }))}
                        />
                      )}
                    </StyledIntroWrapper>
                  </Responsive.Default>
                ) : null}

                {Number(settings['layout.program_page']) ? (
                  <div className="mb-5">
                    <ProgramBestReviewsCarousel
                      pathname={pathname}
                      onReviewBlockScroll={() => customerReviewBlockRef.current?.scrollIntoView({ behavior: 'smooth' })}
                    />
                  </div>
                ) : null}

                <div className="mb-5">
                  <BraftContent>{program.description}</BraftContent>
                </div>

                {!Number(settings['layout.program_page']) ? (
                  <div className="mb-5">
                    <ProgramContentListSection program={program} />
                  </div>
                ) : null}
              </div>

              {Number(settings['layout.program_page']) ? (
                <Responsive.Desktop>
                  <StyledIntroWrapper className="col-12 col-lg-4 mb-3">
                    {!!program.tags.length && (
                      <ProgramTagCard
                        tags={program.tags.map(tag => ({
                          id: tag,
                          name: tag,
                        }))}
                      />
                    )}
                  </StyledIntroWrapper>
                </Responsive.Desktop>
              ) : (
                <StyledIntroWrapper ref={planBlockRef} className="col-12 col-lg-4">
                  <div>
                    <Responsive.Desktop>
                      <ProgramInfoCard instructorId={instructorId} program={program} />
                    </Responsive.Desktop>

                    {!isEnrolledByProgramPackage && (
                      <div className="mb-5">
                        <div id="subscription">
                          {program.plans
                            .filter(programPlan => programPlan.publishedAt)
                            .map(programPlan => (
                              <div key={programPlan.id} className="mb-3">
                                <ProgramPlanCard programId={program.id} programPlan={programPlan} />
                              </div>
                            ))}
                        </div>
                      </div>
                    )}
                  </div>
                </StyledIntroWrapper>
              )}
            </div>

            {!Number(settings['layout.program_page']) ? (
              <div className="row">
                <div className="col-12 col-lg-8">
                  <div className="mb-5">
                    <ProgramInstructorCollectionBlock program={program} />
                  </div>
                </div>
              </div>
            ) : null}

            <div id="customer-review" ref={customerReviewBlockRef}>
              {enabledModules.customer_review && (
                <div className="row">
                  <div className="col-12 col-lg-8">
                    <div className="mb-5">
                      <ReviewCollectionBlock path={pathname} targetId={programId} />
                    </div>
                  </div>
                </div>
              )}
            </div>
          </div>
        </ProgramIntroBlock>
      </div>

      {!isEnrolledByProgramPackage && (
        <Responsive.Default>
          <FixedBottomBlock bottomSpace={visible ? '92px' : ''}>
            {Number(settings['layout.program_page']) ? (
              <StyledButtonWrapper>
                <Link to={isEnrolled ? `/programs/${program.id}/contents` : settings['link.program_page']}>
                  <Button isFullWidth colorScheme="primary" leftIcon={<Icon as={PlayIcon} />}>
                    {formatMessage(defineMessage({ id: 'common.ui.start', defaultMessage: '開始進行' }))}
                  </Button>
                </Link>
              </StyledButtonWrapper>
            ) : isEnrolled ? (
              <StyledButtonWrapper>
                <Link to={`${program.id}/contents`}>
                  <Button variant="primary" isFullWidth>
                    {formatMessage(commonMessages.button.enter)}
                  </Button>
                </Link>
              </StyledButtonWrapper>
            ) : (
              <StyledButtonWrapper>
                <Button
                  variant="primary"
                  isFullWidth
                  onClick={() => planBlockRef.current?.scrollIntoView({ behavior: 'smooth' })}
                >
                  {formatMessage(commonMessages.button.viewProject)}
                </Button>
              </StyledButtonWrapper>
            )}
          </FixedBottomBlock>
        </Responsive.Default>
      )}
    </DefaultLayout>
  )
}

const StyledProgramTagCard = styled.div`
  position: sticky;
  top: 20px;
  margin-top: 20px;
  border-radius: 4px;
  padding: 24px;
  background-color: #fff;
  box-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.15);
`

const StyleSubCategoryTag = styled(Button)`
  && {
    border-radius: 30px;
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
  }
`

const StyledViewAllButton = styled(Button)`
  && {
    font-size: 14px;

    &:hover {
      text-decoration: none;
    }
  }
`

const ProgramTagCard: React.VFC<{ tags: { id: string; name: string }[] }> = ({ tags }) => {
  const { formatMessage } = useIntl()
  const [isOpen, setIsOpen] = useState(false)
  const history = useHistory()

  const resultTags = tags.map(tag => ({
    id: tag.id,
    name: tag.name.includes('/') ? tag.name.split('/')[1] : tag.name,
  }))

  return (
    <StyledProgramTagCard>
      {resultTags.slice(0, 8).map(tag => (
        <StyleSubCategoryTag
          className="mb-2 mr-2"
          variant="outline"
          colorScheme="primary"
          onClick={() =>
            history.push('/search/advanced', {
              tagNameSList: [[tag.id]],
            })
          }
        >
          {tag.name}
        </StyleSubCategoryTag>
      ))}

      {resultTags.length > 8 && (
        <div className="mt-2 mb-3">
          <CommonModal title="" isOpen={isOpen} onClose={() => setIsOpen(false)}>
            {resultTags.map(tag => (
              <StyleSubCategoryTag
                className="mb-2 mr-2"
                variant="outline"
                colorScheme="primary"
                onClick={() =>
                  history.push('/search/advanced', {
                    tagNameSList: [[tag.id]],
                  })
                }
              >
                {tag.name}
              </StyleSubCategoryTag>
            ))}
          </CommonModal>
          <StyledViewAllButton className="d-block" variant="link" onClick={() => setIsOpen(true)}>
            {formatMessage(defineMessage({ id: 'common.ui.viewAll', defaultMessage: '查看全部' }))}
          </StyledViewAllButton>
        </div>
      )}
    </StyledProgramTagCard>
  )
}

export default ProgramPage

明天我們進到最後的部分,「結帳」


上一篇
Program (2)
下一篇
checkout (1)
系列文
從 Open Source 專案學習 React 開發 - 以 lodestar-app 為例30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言