Skip to content

Commit

Permalink
🌳 CRT progress widget (Joystream#4569)
Browse files Browse the repository at this point in the history
* Initial setup

* Finishing touches for UI

* Introduce data into widget

* Create story

* Lint fix

* CR fixes
  • Loading branch information
WRadoslaw committed Apr 22, 2024
1 parent 9f29d42 commit 74e6015
Show file tree
Hide file tree
Showing 5 changed files with 369 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Meta, StoryFn } from '@storybook/react'

import { ProgressWidget, ProgressWidgetProps } from '@/components/ProgressWidget/ProgressWidget'
import { Text } from '@/components/Text'

import { TextButton } from '../_buttons/Button'

export default {
title: 'CRT/ProgressWidget',
component: ProgressWidget,
} as Meta<ProgressWidgetProps>

const steps: ProgressWidgetProps['steps'] = [
{
title: 'Create token',
description: 'Create own token and share it with your viewers!',
primaryButton: {
text: 'Create token',
},
},
{
title: 'Share the good news',
description: 'Share base information about your token with your viewers',
primaryButton: {
text: 'Share now',
},
},
{
title: 'Take your profits',
description: 'Visit dashboard and withdraw first revenue from your token',
primaryButton: {
text: 'To the moon!',
},
},
]

const Template: StoryFn<ProgressWidgetProps> = (args) => <ProgressWidget {...args} />

export const Default = Template.bind({})
Default.args = {
activeStep: 2,
steps,
goalComponent: (
<Text variant="t200" as="p">
Complete 1 more step to achieve <TextButton>Token master</TextButton>
</Text>
),
}
113 changes: 113 additions & 0 deletions packages/atlas/src/components/ProgressWidget/ProgressWidget.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import styled from '@emotion/styled'

import { cVar, sizes, transitions } from '@/styles'

export const Header = styled.div<{ progressWidth: string }>`
background-color: ${cVar('colorBackgroundMuted')};
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
padding: ${sizes(9)} ${sizes(8)};
position: relative;
overflow: hidden;
::after {
content: ' ';
display: block;
background-color: ${cVar('colorBackgroundPrimary')};
position: absolute;
bottom: 0;
height: 4px;
left: 0;
width: ${(props) => props.progressWidth};
}
`

export const RowBox = styled.div<{ gap: number; wrap?: boolean }>`
display: flex;
gap: ${(props) => sizes(props.gap)};
align-items: center;
flex-wrap: ${(props) => (props.wrap ? 'wrap' : 'none')};
`

export const ColumnBox = styled.div<{ gap: number }>`
display: flex;
flex-direction: column;
gap: ${(props) => sizes(props.gap)};
width: 100%;
`

type DrawerProps = {
maxHeight?: number
isActive?: boolean
}

export const DetailsDrawer = styled.div<DrawerProps>`
position: relative;
display: flex;
flex-direction: column;
top: 0;
width: 100%;
max-height: ${({ isActive, maxHeight }) => (isActive ? `${maxHeight}px` : '0px')};
overflow: hidden;
transition: max-height ${transitions.timings.loading} ${transitions.easing};
background-color: ${cVar('colorBackgroundMuted')};
`

export const DropdownContainer = styled.div`
display: flex;
width: 100%;
flex-direction: column;
gap: ${sizes(10)};
padding: ${sizes(4)};
`

export const ProgressBar = styled.div<{ progress: number }>`
position: relative;
height: 12px;
width: 100%;
background-color: ${cVar('colorBackground')};
border-radius: ${sizes(8)};
overflow: hidden;
::after {
content: ' ';
position: absolute;
left: 0;
height: 100%;
background-color: ${cVar('colorBackgroundPrimary')};
width: ${({ progress }) => `${progress}%`};
transition: width 1s linear;
border-radius: ${sizes(8)};
min-width: 25px;
}
`

export const StepCardContainer = styled.div<{ isActive: boolean }>`
display: flex;
flex-direction: column;
gap: ${sizes(6)};
background-color: ${cVar('colorBackgroundMutedAlpha')};
border-left: 4px solid ${(props) => (props.isActive ? cVar('colorBackgroundPrimary') : 'transparent')};
padding: 16px;
.step-number {
background-color: ${(props) =>
props.isActive ? cVar('colorBackgroundPrimary') : cVar('colorBackgroundStrongAlpha')};
}
`

export const StepNumber = styled.div`
display: flex;
align-items: center;
justify-content: center;
background-color: ${cVar('colorBackgroundPrimary')};
border-radius: 50%;
width: 28px;
height: 28px;
`

export const MainWrapper = styled.div`
position: relative;
`
179 changes: 179 additions & 0 deletions packages/atlas/src/components/ProgressWidget/ProgressWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { ReactNode, useRef, useState } from 'react'

import {
SvgActionCheck,
SvgActionChevronB,
SvgActionChevronL,
SvgActionChevronR,
SvgActionChevronT,
SvgActionPlay,
} from '@/assets/icons'
import { Carousel, SwiperInstance } from '@/components/Carousel'
import {
ColumnBox,
DetailsDrawer,
DropdownContainer,
Header,
MainWrapper,
ProgressBar,
RowBox,
StepCardContainer,
StepNumber,
} from '@/components/ProgressWidget/ProgressWidget.styles'
import { Text } from '@/components/Text'
import { Button, ButtonProps } from '@/components/_buttons/Button'
import { useMediaMatch } from '@/hooks/useMediaMatch'

import { getProgressPercentage, responsive } from './ProgressWidget.utils'

type StepButtonProps = {
text: string
} & Omit<ButtonProps, 'children'>

type StepProps = {
title: string
description: string
primaryButton: StepButtonProps
}

export type ProgressWidgetProps = {
steps: StepProps[]
activeStep: number
goalComponent: ReactNode
}

export const ProgressWidget = ({ steps, activeStep, goalComponent }: ProgressWidgetProps) => {
const [isVisible, setIsVisible] = useState(false)
const [glider, setGlider] = useState<SwiperInstance | null>(null)
const drawer = useRef<HTMLDivElement>(null)
const xsMatch = useMediaMatch('xs')
const smMatch = useMediaMatch('sm')
const isDone = activeStep + 1 > steps.length
return (
<MainWrapper>
<Header progressWidth={isVisible ? '0%' : `${getProgressPercentage(activeStep, steps.length)}%`}>
<RowBox gap={4}>
<Text variant="h500" as="h5">
Your progress
</Text>
{!isVisible && (
<Text variant="t200-strong" as="p">
({isDone ? steps.length : activeStep}/{steps.length})
</Text>
)}
</RowBox>
{xsMatch && (
<Button
onClick={() => setIsVisible((prev) => !prev)}
icon={isVisible ? <SvgActionChevronT /> : <SvgActionChevronB />}
iconPlacement="right"
variant="tertiary"
>
{isVisible ? 'Show less' : 'Show more'}
</Button>
)}
</Header>
<DetailsDrawer isActive={isVisible} ref={drawer} maxHeight={drawer?.current?.scrollHeight}>
<DropdownContainer>
<RowBox gap={12}>
<ExtendedProgressBar
activeStep={steps[isDone ? steps.length - 1 : activeStep]}
activeStepNumber={activeStep}
totalSteps={steps.length}
goalComponent={goalComponent}
/>
{smMatch && (
<RowBox gap={4}>
<Button icon={<SvgActionChevronL />} onClick={() => glider?.slidePrev()} variant="secondary" />
<Button icon={<SvgActionChevronR />} onClick={() => glider?.slideNext()} variant="secondary" />
</RowBox>
)}
</RowBox>
<Carousel
initialSlide={activeStep + 1}
spaceBetween={12}
navigation
dotsVisible
breakpoints={responsive}
onSwiper={(swiper) => setGlider(swiper)}
>
{steps.map((step, idx) => (
<StepCard
key={step.title}
step={step}
status={idx === activeStep ? 'active' : idx < activeStep ? 'done' : 'next'}
stepNumber={idx + 1}
/>
))}
</Carousel>
</DropdownContainer>
</DetailsDrawer>
</MainWrapper>
)
}
type ExtendedProgressBarProps = {
activeStep: StepProps
activeStepNumber: number
totalSteps: number
goalComponent: ReactNode
}
const ExtendedProgressBar = ({ activeStep, activeStepNumber, totalSteps, goalComponent }: ExtendedProgressBarProps) => {
const isDone = activeStepNumber + 1 > totalSteps

return (
<ColumnBox gap={2}>
<Text variant="t300-strong" as="p">
{activeStep.title}
</Text>
<RowBox gap={4}>
<ProgressBar progress={getProgressPercentage(activeStepNumber, totalSteps)} />
<Text variant="t200-strong" as="p">
{isDone ? totalSteps : activeStepNumber}/{totalSteps}
</Text>
</RowBox>
{goalComponent}
</ColumnBox>
)
}

type StepCardProps = {
step: StepProps
status: 'active' | 'done' | 'next'
stepNumber: number
}

const StepCard = ({ step, status, stepNumber }: StepCardProps) => {
const smMatch = useMediaMatch('xs')

return (
<StepCardContainer isActive={status === 'active'}>
<StepNumber className="step-number">
<Text variant="t200-strong" as="p">
{status === 'done' ? <SvgActionCheck /> : stepNumber}
</Text>
</StepNumber>
<ColumnBox gap={2}>
<Text variant="h300" as="h3">
{step.title}
</Text>
<Text variant="t200" as="p">
{step.description}
</Text>
</ColumnBox>

<RowBox gap={4} wrap>
<Button
{...step.primaryButton}
variant={status === 'active' ? 'primary' : 'secondary'}
disabled={status === 'done'}
fullWidth={!smMatch}
>
{step.primaryButton.text}
</Button>
<Button variant="tertiary" fullWidth={!smMatch} _textOnly icon={<SvgActionPlay />} iconPlacement="right">
Learn more
</Button>
</RowBox>
</StepCardContainer>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CarouselProps } from '@/components/Carousel'
import { breakpoints } from '@/styles'

export const responsive: CarouselProps['breakpoints'] = {
[parseInt(breakpoints.xs)]: {
slidesPerView: 1,
slidesPerGroup: 1,
},
[parseInt(breakpoints.sm)]: {
slidesPerView: 2,
slidesPerGroup: 2,
},
[parseInt(breakpoints.lg)]: {
slidesPerView: 3,
slidesPerGroup: 3,
},
[parseInt(breakpoints.xl)]: {
slidesPerView: 4,
slidesPerGroup: 4,
},
[parseInt(breakpoints.xxl)]: {
slidesPerView: 5,
slidesPerGroup: 5,
},
}

export const getProgressPercentage = (activeStep: number, totalStepCount: number) =>
Math.round((activeStep / totalStepCount) * 100)
1 change: 1 addition & 0 deletions packages/atlas/src/components/ProgressWidget/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ProgressWidget'

0 comments on commit 74e6015

Please sign in to comment.