Merge pull request #1911 from SkynetLabs/dashboard-v2-upgrade-page
Dashboard v2 upgrade page
This commit is contained in:
commit
b2a7a2a8a4
|
@ -1,3 +1,4 @@
|
|||
import cn from "classnames";
|
||||
import PropTypes from "prop-types";
|
||||
import styled from "styled-components";
|
||||
|
||||
|
@ -6,9 +7,12 @@ import styled from "styled-components";
|
|||
*/
|
||||
export const Button = styled.button.attrs(({ disabled, $primary }) => ({
|
||||
type: "button",
|
||||
className: `px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide text-palette-600 transition-[filter]
|
||||
${$primary ? "bg-primary" : "bg-white border-2 border-black"}
|
||||
${disabled ? "saturate-50 brightness-125 cursor-default text-palette-400" : "hover:brightness-90"}`,
|
||||
className: cn("px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide transition-[opacity_filter]", {
|
||||
"bg-primary text-palette-600": $primary,
|
||||
"bg-white border-2 border-black text-palette-600": !$primary,
|
||||
"cursor-not-allowed opacity-60": disabled,
|
||||
"hover:brightness-90": !disabled,
|
||||
}),
|
||||
}))``;
|
||||
Button.propTypes = {
|
||||
/**
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import PropTypes from "prop-types";
|
||||
|
||||
import { withIconProps } from "../withIconProps";
|
||||
|
||||
export const CircledCheckmarkIcon = withIconProps(({ size, ...props }) => (
|
||||
export const CheckmarkIcon = withIconProps(({ size, circled, ...props }) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
|
@ -9,10 +11,15 @@ export const CircledCheckmarkIcon = withIconProps(({ size, ...props }) => (
|
|||
shapeRendering="geometricPrecision"
|
||||
{...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
|
||||
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"
|
||||
/>
|
||||
</svg>
|
||||
));
|
||||
|
||||
CheckmarkIcon.propTypes = {
|
||||
...CheckmarkIcon.propTypes,
|
||||
circled: PropTypes.bool,
|
||||
};
|
|
@ -4,7 +4,7 @@ export * from "./icons/LockClosedIcon";
|
|||
export * from "./icons/SkynetLogoIcon";
|
||||
export * from "./icons/ArrowRightIcon";
|
||||
export * from "./icons/InfoIcon";
|
||||
export * from "./icons/CircledCheckmarkIcon";
|
||||
export * from "./icons/CheckmarkIcon";
|
||||
export * from "./icons/CircledErrorIcon";
|
||||
export * from "./icons/CircledProgressIcon";
|
||||
export * from "./icons/CircledArrowUpIcon";
|
||||
|
|
|
@ -6,7 +6,7 @@ export default function Bullets({ visibleSlides, activeIndex, allSlides, changeS
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 pt-6">
|
||||
<div className="flex gap-3 pt-6 justify-center sm:justify-start">
|
||||
{Array(allSlides - visibleSlides + 1)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
|
|
|
@ -2,7 +2,7 @@ import styled from "styled-components";
|
|||
import PropTypes from "prop-types";
|
||||
|
||||
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 = {
|
||||
|
|
|
@ -22,12 +22,12 @@ const scrollableStyles = css`
|
|||
`;
|
||||
|
||||
const Scroller = styled.div.attrs({
|
||||
className: "slider-scroller grid gap-4 transition-transform",
|
||||
className: "slider-scroller grid transition-transform",
|
||||
})`
|
||||
${({ $scrollable }) => ($scrollable ? scrollableStyles : "")}
|
||||
`;
|
||||
|
||||
const Slider = ({ slides, breakpoints }) => {
|
||||
const Slider = ({ slides, breakpoints, scrollerClassName, className }) => {
|
||||
const { visibleSlides, scrollable } = useActiveBreakpoint(breakpoints);
|
||||
const [activeIndex, setActiveIndex] = React.useState(0);
|
||||
const changeSlide = React.useCallback(
|
||||
|
@ -49,12 +49,13 @@ const Slider = ({ slides, breakpoints }) => {
|
|||
}, [slides.length, visibleSlides, activeIndex]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Container className={className}>
|
||||
<Scroller
|
||||
$visibleSlides={visibleSlides}
|
||||
$allSlides={slides.length}
|
||||
$activeIndex={activeIndex}
|
||||
$scrollable={scrollable}
|
||||
className={scrollerClassName}
|
||||
>
|
||||
{slides.map((slide, index) => {
|
||||
const isVisible = index >= activeIndex && index < activeIndex + visibleSlides;
|
||||
|
@ -63,7 +64,11 @@ const Slider = ({ slides, breakpoints }) => {
|
|||
<div key={`slide-${index}`}>
|
||||
<Slide
|
||||
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>
|
||||
|
@ -101,6 +106,14 @@ Slider.propTypes = {
|
|||
* If set to false, all slides will be visible & rendered in a column.
|
||||
*/
|
||||
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,
|
||||
},
|
||||
],
|
||||
scrollerClassName: "gap-4",
|
||||
className: "",
|
||||
};
|
||||
|
||||
export default Slider;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import cn from "classnames";
|
||||
|
||||
import { CircledCheckmarkIcon, CircledErrorIcon, CircledProgressIcon, CircledArrowUpIcon } from "../Icons";
|
||||
import { CheckmarkIcon, CircledErrorIcon, CircledProgressIcon, CircledArrowUpIcon } from "../Icons";
|
||||
|
||||
export default function UploaderItemIcon({ status }) {
|
||||
switch (status) {
|
||||
|
@ -11,7 +11,7 @@ export default function UploaderItemIcon({ status }) {
|
|||
case "processing":
|
||||
return <CircledProgressIcon className="animate-[spin_3s_linear_infinite]" />;
|
||||
case "complete":
|
||||
return <CircledCheckmarkIcon />;
|
||||
return <CheckmarkIcon circled />;
|
||||
case "error":
|
||||
return <CircledErrorIcon className="text-error" />;
|
||||
default:
|
||||
|
|
|
@ -4,7 +4,7 @@ import freeTier from "../lib/tiers";
|
|||
import { usePlans } from "../contexts/plans";
|
||||
|
||||
export default function useActivePlan(user) {
|
||||
const { plans, error } = usePlans();
|
||||
const { plans, loading, error } = usePlans();
|
||||
|
||||
const [activePlan, setActivePlan] = useState(freeTier);
|
||||
|
||||
|
@ -17,6 +17,7 @@ export default function useActivePlan(user) {
|
|||
return {
|
||||
error,
|
||||
plans,
|
||||
loading,
|
||||
activePlan,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
Reference in New Issue