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}
{
if (!localStorage) return;
try {
- return localStorage.setItem(key, JSON.stringify(value));
+ localStorage.setItem(key, JSON.stringify(value));
+ window.dispatchEvent(new Event('storage:updated'));
} catch (err) {}
};
@@ -18,6 +19,15 @@ export const removeItemFromStorage = key => {
if (!localStorage) return;
try {
- return localStorage.removeItem(key);
+ localStorage.removeItem(key);
+ window.dispatchEvent(new Event('storage:updated'));
} catch (err) {}
};
+
+export const getLocalStorageLength = () => {
+ if (!localStorage) return;
+
+ try {
+ return localStorage.length;
+ } catch (err) {}
+}
\ No newline at end of file
diff --git a/pages/about.js b/pages/about.js
index 4846f03..fdc66b6 100644
--- a/pages/about.js
+++ b/pages/about.js
@@ -5,7 +5,7 @@ import Page, { SectionHeading, Heading, Subheading } from '../components/Page';
export default function About() {
return (
-
+
{
render() {
return (
-
+
Be safe on the internet.