Merge pull request #1911 from SkynetLabs/dashboard-v2-upgrade-page

Dashboard v2 upgrade page
This commit is contained in:
Karol Wypchło 2022-03-24 11:59:40 +01:00 committed by GitHub
commit b2a7a2a8a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 176 additions and 15 deletions

View File

@ -1,3 +1,4 @@
import cn from "classnames";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import styled from "styled-components"; import styled from "styled-components";
@ -6,9 +7,12 @@ import styled from "styled-components";
*/ */
export const Button = styled.button.attrs(({ disabled, $primary }) => ({ export const Button = styled.button.attrs(({ disabled, $primary }) => ({
type: "button", type: "button",
className: `px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide text-palette-600 transition-[filter] className: cn("px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide transition-[opacity_filter]", {
${$primary ? "bg-primary" : "bg-white border-2 border-black"} "bg-primary text-palette-600": $primary,
${disabled ? "saturate-50 brightness-125 cursor-default text-palette-400" : "hover:brightness-90"}`, "bg-white border-2 border-black text-palette-600": !$primary,
"cursor-not-allowed opacity-60": disabled,
"hover:brightness-90": !disabled,
}),
}))``; }))``;
Button.propTypes = { Button.propTypes = {
/** /**

View File

@ -1,6 +1,8 @@
import PropTypes from "prop-types";
import { withIconProps } from "../withIconProps"; import { withIconProps } from "../withIconProps";
export const CircledCheckmarkIcon = withIconProps(({ size, ...props }) => ( export const CheckmarkIcon = withIconProps(({ size, circled, ...props }) => (
<svg <svg
width={size} width={size}
height={size} height={size}
@ -9,10 +11,15 @@ export const CircledCheckmarkIcon = withIconProps(({ size, ...props }) => (
shapeRendering="geometricPrecision" shapeRendering="geometricPrecision"
{...props} {...props}
> >
<circle cx="16" cy="16" r="15" fill="transparent" stroke="currentColor" strokeWidth="2" /> {circled && <circle cx="16" cy="16" r="15" fill="transparent" stroke="currentColor" strokeWidth="2" />}
<polygon <polygon
fill="currentColor" fill="currentColor"
points="22.45 11.19 23.86 12.61 14.44 22.03 9.69 17.28 11.1 15.86 14.44 19.2 22.45 11.19" points="22.45 11.19 23.86 12.61 14.44 22.03 9.69 17.28 11.1 15.86 14.44 19.2 22.45 11.19"
/> />
</svg> </svg>
)); ));
CheckmarkIcon.propTypes = {
...CheckmarkIcon.propTypes,
circled: PropTypes.bool,
};

View File

@ -4,7 +4,7 @@ export * from "./icons/LockClosedIcon";
export * from "./icons/SkynetLogoIcon"; export * from "./icons/SkynetLogoIcon";
export * from "./icons/ArrowRightIcon"; export * from "./icons/ArrowRightIcon";
export * from "./icons/InfoIcon"; export * from "./icons/InfoIcon";
export * from "./icons/CircledCheckmarkIcon"; export * from "./icons/CheckmarkIcon";
export * from "./icons/CircledErrorIcon"; export * from "./icons/CircledErrorIcon";
export * from "./icons/CircledProgressIcon"; export * from "./icons/CircledProgressIcon";
export * from "./icons/CircledArrowUpIcon"; export * from "./icons/CircledArrowUpIcon";

View File

@ -6,7 +6,7 @@ export default function Bullets({ visibleSlides, activeIndex, allSlides, changeS
} }
return ( return (
<div className="flex gap-3 pt-6"> <div className="flex gap-3 pt-6 justify-center sm:justify-start">
{Array(allSlides - visibleSlides + 1) {Array(allSlides - visibleSlides + 1)
.fill(null) .fill(null)
.map((_, index) => ( .map((_, index) => (

View File

@ -2,7 +2,7 @@ import styled from "styled-components";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
const Slide = styled.div.attrs(({ isVisible }) => ({ const Slide = styled.div.attrs(({ isVisible }) => ({
className: `slider-slide transition-opacity ${isVisible ? "" : "opacity-50 cursor-pointer"}`, className: `slider-slide transition-opacity ${isVisible ? "" : "opacity-50 cursor-pointer select-none"}`,
}))``; }))``;
Slide.propTypes = { Slide.propTypes = {

View File

@ -22,12 +22,12 @@ const scrollableStyles = css`
`; `;
const Scroller = styled.div.attrs({ const Scroller = styled.div.attrs({
className: "slider-scroller grid gap-4 transition-transform", className: "slider-scroller grid transition-transform",
})` })`
${({ $scrollable }) => ($scrollable ? scrollableStyles : "")} ${({ $scrollable }) => ($scrollable ? scrollableStyles : "")}
`; `;
const Slider = ({ slides, breakpoints }) => { const Slider = ({ slides, breakpoints, scrollerClassName, className }) => {
const { visibleSlides, scrollable } = useActiveBreakpoint(breakpoints); const { visibleSlides, scrollable } = useActiveBreakpoint(breakpoints);
const [activeIndex, setActiveIndex] = React.useState(0); const [activeIndex, setActiveIndex] = React.useState(0);
const changeSlide = React.useCallback( const changeSlide = React.useCallback(
@ -49,12 +49,13 @@ const Slider = ({ slides, breakpoints }) => {
}, [slides.length, visibleSlides, activeIndex]); }, [slides.length, visibleSlides, activeIndex]);
return ( return (
<Container> <Container className={className}>
<Scroller <Scroller
$visibleSlides={visibleSlides} $visibleSlides={visibleSlides}
$allSlides={slides.length} $allSlides={slides.length}
$activeIndex={activeIndex} $activeIndex={activeIndex}
$scrollable={scrollable} $scrollable={scrollable}
className={scrollerClassName}
> >
{slides.map((slide, index) => { {slides.map((slide, index) => {
const isVisible = index >= activeIndex && index < activeIndex + visibleSlides; const isVisible = index >= activeIndex && index < activeIndex + visibleSlides;
@ -63,7 +64,11 @@ const Slider = ({ slides, breakpoints }) => {
<div key={`slide-${index}`}> <div key={`slide-${index}`}>
<Slide <Slide
isVisible={isVisible || !scrollable} isVisible={isVisible || !scrollable}
onClickCapture={scrollable && !isVisible ? (event) => changeSlide(event, index) : null} onClickCapture={
scrollable && !isVisible
? (event) => changeSlide(event, index > activeIndex ? activeIndex + 1 : activeIndex - 1)
: null
}
> >
{slide} {slide}
</Slide> </Slide>
@ -101,6 +106,14 @@ Slider.propTypes = {
* If set to false, all slides will be visible & rendered in a column. * If set to false, all slides will be visible & rendered in a column.
*/ */
scrollable: PropTypes.bool.isRequired, scrollable: PropTypes.bool.isRequired,
/**
* Additional class names to apply to the <Scroller /> element.
*/
scrollerClassName: PropTypes.string,
/**
* Additional class names to apply to the <Container /> element.
*/
className: PropTypes.string,
}) })
), ),
}; };
@ -123,6 +136,8 @@ Slider.defaultProps = {
visibleSlides: 1, visibleSlides: 1,
}, },
], ],
scrollerClassName: "gap-4",
className: "",
}; };
export default Slider; export default Slider;

View File

@ -1,6 +1,6 @@
import cn from "classnames"; import cn from "classnames";
import { CircledCheckmarkIcon, CircledErrorIcon, CircledProgressIcon, CircledArrowUpIcon } from "../Icons"; import { CheckmarkIcon, CircledErrorIcon, CircledProgressIcon, CircledArrowUpIcon } from "../Icons";
export default function UploaderItemIcon({ status }) { export default function UploaderItemIcon({ status }) {
switch (status) { switch (status) {
@ -11,7 +11,7 @@ export default function UploaderItemIcon({ status }) {
case "processing": case "processing":
return <CircledProgressIcon className="animate-[spin_3s_linear_infinite]" />; return <CircledProgressIcon className="animate-[spin_3s_linear_infinite]" />;
case "complete": case "complete":
return <CircledCheckmarkIcon />; return <CheckmarkIcon circled />;
case "error": case "error":
return <CircledErrorIcon className="text-error" />; return <CircledErrorIcon className="text-error" />;
default: default:

View File

@ -4,7 +4,7 @@ import freeTier from "../lib/tiers";
import { usePlans } from "../contexts/plans"; import { usePlans } from "../contexts/plans";
export default function useActivePlan(user) { export default function useActivePlan(user) {
const { plans, error } = usePlans(); const { plans, loading, error } = usePlans();
const [activePlan, setActivePlan] = useState(freeTier); const [activePlan, setActivePlan] = useState(freeTier);
@ -17,6 +17,7 @@ export default function useActivePlan(user) {
return { return {
error, error,
plans, plans,
loading,
activePlan, activePlan,
}; };
} }

View File

@ -0,0 +1,134 @@
import * as React from "react";
import bytes from "pretty-bytes";
import styled from "styled-components";
import { useUser } from "../contexts/user";
import { PlansProvider } from "../contexts/plans/PlansProvider";
import useActivePlan from "../hooks/useActivePlan";
import DashboardLayout from "../layouts/DashboardLayout";
import { Panel } from "../components/Panel";
import Slider from "../components/Slider/Slider";
import { CheckmarkIcon } from "../components/Icons";
import { Button } from "../components/Button";
const SLIDER_BREAKPOINTS = [
{
name: "xl",
scrollable: true,
visibleSlides: 4,
},
{
name: "lg",
scrollable: true,
visibleSlides: 3,
},
{
name: "sm",
scrollable: true,
visibleSlides: 2,
},
{
scrollable: true,
visibleSlides: 1,
},
];
const PlanSummaryItem = ({ children }) => (
<li className="flex items-start gap-1 my-2">
<CheckmarkIcon size={32} className="text-primary shrink-0" />
<div className="mt-1">{children}</div>
</li>
);
const Description = styled.div`
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
flex-shrink: 0;
height: 6rem;
`;
const Price = ({ price }) => (
<div className="my-8 text-center">
<h2>
<sup className="text-lg -top-4">$</sup>
{price}
</h2>
<p className="uppercase text-sm font-light -mt-2">per month</p>
</div>
);
const bandwidth = (value) => `${bytes(value, { bits: true })}/s`;
const storage = (value) => bytes(value, { binary: true });
const localizedNumber = (value) => value.toLocaleString();
const PlansSlider = () => {
const { user, error: userError } = useUser();
const { plans, loading, activePlan, error: plansError } = useActivePlan(user);
if (userError || plansError) {
return (
<div className="flex flex-col items-center justify-center">
<h3>Oooops!</h3>
<p>Something went wrong, please try again later.</p>
</div>
);
}
return (
<div className="w-full mb-24">
{!loading && (
<Slider
slides={plans.map((plan) => {
const isHigherThanCurrent = plan.tier > activePlan?.tier;
const isCurrent = plan.tier === activePlan?.tier;
return (
<Panel className="min-h-[620px] px-6 py-10 flex flex-col">
<h3>{plan.name}</h3>
<Description>{plan.description}</Description>
<Price price={plan.price} />
<div className="text-center my-6">
<Button $primary={isHigherThanCurrent} disabled={isCurrent}>
{isHigherThanCurrent && "Upgrade"}
{isCurrent && "Current"}
{!isHigherThanCurrent && !isCurrent && "Choose"}
</Button>
</div>
{plan.limits && (
<ul className="-ml-2">
<PlanSummaryItem>
Pin up to {storage(plan.limits.storageLimit)} of censorship-resistant storage
</PlanSummaryItem>
<PlanSummaryItem>
Support for up to {localizedNumber(plan.limits.maxNumberUploads)} files
</PlanSummaryItem>
<PlanSummaryItem>{bandwidth(plan.limits.uploadBandwidth)} upload bandwidth</PlanSummaryItem>
<PlanSummaryItem>{bandwidth(plan.limits.downloadBandwidth)} download bandwidth</PlanSummaryItem>
</ul>
)}
</Panel>
);
})}
breakpoints={SLIDER_BREAKPOINTS}
scrollerClassName="gap-4 xl:gap-8"
className="px-8 sm:px-4 md:px-0 lg:px-0"
/>
)}
</div>
);
};
const UpgradePage = () => (
<PlansProvider>
<PlansSlider />
</PlansProvider>
);
UpgradePage.Layout = DashboardLayout;
export default UpgradePage;