diff --git a/components/Header/Confetti.js b/components/Header/Confetti.js new file mode 100644 index 0000000..4560612 --- /dev/null +++ b/components/Header/Confetti.js @@ -0,0 +1,172 @@ +// @flow + +// The code in this component was based on Yoav Kadosh’s React Confetti +// on Codepen : // https://codepen.io/ykadosh/pen/aaoZRB +// distributed under the following license: +// +// Copyright (c) 2019 by Yoav Kadosh (https://codepen.io/ykadosh/pen/aaoZRB) +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +import React, { useState, useRef, useEffect, Fragment } from 'react'; +import { ParticleZone } from './style'; + +type ConfettiProps = { + fireConfetti: boolean, +}; +type ParticlesProps = { + count: number, +}; +type Props = {}; + + +const COLORS = ['#2ecc71','#3498db','#e67e22','#e67e22','#e74c3c']; +const TOP_OFFSET = typeof window != 'undefined' ? window.innerHeight : 0; +const INNER_WIDTH = typeof window != 'undefined' ? window.innerWidth : 0; +const LEFT_OFFSET = 250; + +const generateWholeNumber = (min, max) => min + Math.floor(Math.random()*(max - min)); + +const generateRandomColor = () => COLORS[generateWholeNumber(0,COLORS.length)]; + +export function CircularParticle(props: Props) { + const currentCircularConfetti = useRef(null); + + const SIZE_RANGE = [5, 10]; + const ROTATION_RANGE = [0, 45]; + + const size = generateWholeNumber(...SIZE_RANGE); + const style = { + backgroundColor: generateRandomColor(), + width: size, + height: size, + borderRadius: size, + transform: `rotateZ(${generateWholeNumber(...ROTATION_RANGE)}deg)`, + left: generateWholeNumber(0, INNER_WIDTH), + top: generateWholeNumber(-TOP_OFFSET, 0) + }; + + useEffect(() => { + const {left} = style; + setTimeout(() => { + const node = currentCircularConfetti.current; + if (node) { + node.style.top = window.innerHeight + generateWholeNumber(0, window.innerHeight) + 'px'; + node.style.left = left + generateWholeNumber(-LEFT_OFFSET, LEFT_OFFSET) + 'px'; + } + },0); + }); + + return ( +
+ ); +} + +function SquiggleParticle(props: Props) { + const currentSquiggleConfetti = useRef(null); + const SIZE_RANGE = [15, 45]; + const ROTATION_RANGE = [-15, 15]; + + const size = generateWholeNumber(...SIZE_RANGE); + const style = { + fill: generateRandomColor(), + width: size, + height: size, + transform: `rotateZ(${generateWholeNumber(...ROTATION_RANGE)}deg)`, + left: generateWholeNumber(0, INNER_WIDTH), + top: generateWholeNumber(-TOP_OFFSET, 0) + }; + + + useEffect(() => { + const {left} = style; + setTimeout(() => { + const node = currentSquiggleConfetti.current; + if (node){ + node.style.top = window.innerHeight + generateWholeNumber(0, window.innerHeight) + 'px'; + node.style.left = left + generateWholeNumber(-LEFT_OFFSET, LEFT_OFFSET) + 'px'; + node.style.transform = `rotateZ(${generateWholeNumber(...ROTATION_RANGE)}deg)`; + } + },0); + }); + + return ( +
+ + + +
+ ); +} + +export function Particles(props: ParticlesProps) { + let {count: n} = props; + const particles = []; + const types = [SquiggleParticle, CircularParticle, CircularParticle]; + + while(n--) { + const Particle = types[generateWholeNumber(0,3)]; + particles.push( + + ); + } + + return ( + + {particles} + + ); +} + + +export default function Confetti(props: ConfettiProps) { + const [particles, setParticles] = useState([]); + + var id = 1; + + function clean(id) { + setParticles(particles.filter(_id => _id !== id)) + } + + useEffect(() => { + if (props.fireConfetti) { + id = id; + id++; + + setParticles([...particles, id]) + + setTimeout(() => { + clean(id); + }, 5000); + } + }, + [props.fireConfetti] + ); + + return ( + + {props.fireConfetti && particles.map(id => ( + + ))} + + ); +} diff --git a/components/Header/index.js b/components/Header/index.js index 3552c43..5efbf0a 100644 --- a/components/Header/index.js +++ b/components/Header/index.js @@ -1,16 +1,27 @@ // @flow import * as React from 'react'; import Link from 'next/link'; -import { Container, ButtonRowContainer, Label, LogoLink } from './style'; +import { + Container, + ButtonRowContainer, + Label, + LogoLink, + Progression, + ProgressBar, + ProgressLabel } from './style'; import { PrimaryButton, GhostButton } from '../Button'; import Logo from './Logo'; +import Confetti from './Confetti'; type Props = { showHeaderShadow: boolean, + displayProgress: boolean, + totalItemsCount: number, + currentCount: number, }; export default function Header(props: Props) { - const { showHeaderShadow } = props; + const { showHeaderShadow, totalItemsCount, currentCount, displayProgress } = props; return ( @@ -39,6 +50,28 @@ export default function Header(props: Props) { Contribute + + { displayProgress && ( + + 0 ? false : true} + /> + + { currentCount === totalItemsCount + ? `🎉 Checklist complete! 🎉` + : `${currentCount} of ${totalItemsCount} completed`} + + + )} ); } diff --git a/components/Header/style.js b/components/Header/style.js index 8492fb2..fcfd103 100644 --- a/components/Header/style.js +++ b/components/Header/style.js @@ -2,27 +2,24 @@ import styled from 'styled-components'; import { theme } from '../theme'; import { hexa } from '../globals'; +import { Shadows } from '../globals'; export const Container = styled.div` display: grid; grid-template-columns: 1fr 1fr; - grid-template-areas: 'logo actions'; - padding: 16px; + grid-template-areas: "logo actions"; + padding: 16px 16px; position: fixed; top: 0; left: 0; right: 0; - background: ${props => - props.showHeaderShadow ? props.theme.bg.default : props.theme.bg.wash}; + background: ${theme.bg.default}; z-index: 3; - box-shadow: ${props => - props.showHeaderShadow ? '0 4px 8px rgba(0,0,0,0.04)' : 'none'}; + box-shadow: 0 4px 8px rgba(0,0,0,0.04); transition: all 0.2s ease-in-out; @media (max-width: 968px) { padding: 8px 16px; - grid-template-columns: 1fr 1fr; - grid-template-areas: 'logo actions'; } `; @@ -33,6 +30,88 @@ export const Logo = styled.h1` color: ${theme.text.default}; `; +export const Progression = styled.div` + text-align: center; + position: absolute; + left: 0; + right: 0; + height: 32px; + width: 100%; + bottom: -16px; + display: block; + background: transparent; + + &:active, &:focus { + outline: none; + } +`; + +export const ProgressBar = styled.div` + display: block; + height: 4px; + width: 100%; + margin: 16px 0 0; + position: relative; + overflow: hidden; + background-image: linear-gradient(to left, #a913de, #6ac9ff); + box-shadow: 0 4px 8px rgba(0,0,0,0.04); + z-index: 5; + + ${Progression}:focus &, + ${Progression}:active & { + box-shadow: 0 0 0 1px ${theme.bg.default}, + 0 0 0 3px ${props => hexa(props.theme.brand.default, 0.25)}; + } + + &::after { + content: ''; + position: absolute; + height: 100%; + top: 0; + right: 0; + width: 100%; + background: ${theme.border.default}; + max-width: var(--progress); + transition: max-width ${theme.animations.default}, background ${theme.animations.default}; + } + &[disabled]::after { + background: ${theme.bg.default}; + } +`; + +export const ProgressLabel = styled.p` + visibility: hidden; + opacity: 0; + position: absolute; + z-index: 4; + bottom: -25%; + left: 50%; + transform: translateX(-50%); + background: ${theme.bg.default}; + padding: 8px 16px; + font-size: 14px; + font-weight: 600; + + transition: all ${theme.animations.default}; + + border-radius: 8px; + white-space: nowrap; + ${Shadows.default}; + + ${Progression}:hover ${ProgressBar}:not([disabled]) + &, + ${Progression}:focus ${ProgressBar}:not([disabled]) + &, + ${Progression}:active ${ProgressBar}:not([disabled]) + & + { + visibility: visible; + opacity: 1; + bottom: -100%; + } + + @media (max-width: 968px) { + padding: 8px 12px; + } +` + export const ButtonRowContainer = styled.div` display: flex; justify-content: flex-end; @@ -49,6 +128,7 @@ export const LogoLink = styled.a` display: inline-flex; align-items: center; border-radius: 6px; + height: 100%; &:hover { transform: scale(1.2); @@ -65,3 +145,17 @@ export const Label = styled.h1` left: -9999px; visibility: none; `; + +export const ParticleZone = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + + & > div, & > svg { + position: absolute; + transition: all 5s ease-out; + } +` + diff --git a/components/Page/index.js b/components/Page/index.js index 13fddb7..c53823c 100644 --- a/components/Page/index.js +++ b/components/Page/index.js @@ -17,18 +17,25 @@ import { ScrollToTop, } from './style'; import * as gtag from '../../lib/gtag'; +import { getLocalStorageLength } from '../../lib/localStorage'; +import data from '../../config/data'; export { SectionHeading, Heading, Subheading }; type Props = { children: Node, + displayProgress: boolean, }; +const totalItemsCount = Object.keys(data).length; + export default function Page(props: Props) { - const { children } = props; + const { children, displayProgress } = props; const [lastTrackedPageview, setLastTrackedPageview] = useState(null); const [showHeaderShadow, setHeaderShadow] = useState(false); const [scrollToTopVisible, setScrollToTopVisible] = useState(false); + const [progress, setProgress] = useState(0); + const [currentCount, setCurrentCount] = useState(0); function handleScroll() { const headerShadowState = window && window.pageYOffset > 0; @@ -39,6 +46,13 @@ export default function Page(props: Props) { const throttledScroll = throttle(300, handleScroll); + function updateProgress() { + const checkedItemsCount = getLocalStorageLength(); + const progressPercentage = checkedItemsCount * 100 / totalItemsCount; + setProgress(progressPercentage); + setCurrentCount(checkedItemsCount); + }; + const scrollToTop = () => { if (window) { window.scrollTo(0, 0); @@ -46,6 +60,8 @@ export default function Page(props: Props) { }; useEffect(() => { + updateProgress(); + if (window) { window.addEventListener('scroll', throttledScroll); } @@ -56,7 +72,18 @@ export default function Page(props: Props) { setLastTrackedPageview(null); } }; - }, []); + }, [progress]); + + useEffect(() => { + if (window && displayProgress) { + window.addEventListener('storage:updated', updateProgress ); + } + return () => { + if (window && displayProgress) { + window.removeEventListener('storage:updated', updateProgress ); + } + }; + }); useEffect(() => { if (document) { @@ -71,7 +98,18 @@ export default function Page(props: Props) { return ( -
+ + +
{children}