diff --git a/docker-compose.accounts.yml b/docker-compose.accounts.yml index 221badf8..99b10ff4 100644 --- a/docker-compose.accounts.yml +++ b/docker-compose.accounts.yml @@ -89,6 +89,7 @@ services: - .env environment: - GATSBY_PORTAL_DOMAIN=${PORTAL_DOMAIN} + - GATSBY_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY} volumes: - ./docker/data/dashboard-v2/.cache:/usr/app/.cache - ./docker/data/dashboard-v2/public:/usr/app/public diff --git a/packages/dashboard-v2/gatsby-browser.js b/packages/dashboard-v2/gatsby-browser.js index 030fcadd..927fd206 100644 --- a/packages/dashboard-v2/gatsby-browser.js +++ b/packages/dashboard-v2/gatsby-browser.js @@ -1,5 +1,7 @@ import * as React from "react"; import { SWRConfig } from "swr"; +import { Elements } from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; import "@fontsource/sora/300.css"; // light import "@fontsource/sora/400.css"; // normal import "@fontsource/sora/500.css"; // medium @@ -11,15 +13,19 @@ import swrConfig from "./src/lib/swrConfig"; import { MODAL_ROOT_ID } from "./src/components/Modal"; import { PortalSettingsProvider } from "./src/contexts/portal-settings"; +const stripePromise = loadStripe(process.env.GATSBY_STRIPE_PUBLISHABLE_KEY); + export function wrapPageElement({ element, props }) { const Layout = element.type.Layout ?? React.Fragment; return ( - - {element} -
- + + + {element} +
+ + ); diff --git a/packages/dashboard-v2/gatsby-ssr.js b/packages/dashboard-v2/gatsby-ssr.js index 030fcadd..927fd206 100644 --- a/packages/dashboard-v2/gatsby-ssr.js +++ b/packages/dashboard-v2/gatsby-ssr.js @@ -1,5 +1,7 @@ import * as React from "react"; import { SWRConfig } from "swr"; +import { Elements } from "@stripe/react-stripe-js"; +import { loadStripe } from "@stripe/stripe-js"; import "@fontsource/sora/300.css"; // light import "@fontsource/sora/400.css"; // normal import "@fontsource/sora/500.css"; // medium @@ -11,15 +13,19 @@ import swrConfig from "./src/lib/swrConfig"; import { MODAL_ROOT_ID } from "./src/components/Modal"; import { PortalSettingsProvider } from "./src/contexts/portal-settings"; +const stripePromise = loadStripe(process.env.GATSBY_STRIPE_PUBLISHABLE_KEY); + export function wrapPageElement({ element, props }) { const Layout = element.type.Layout ?? React.Fragment; return ( - - {element} -
- + + + {element} +
+ + ); diff --git a/packages/dashboard-v2/package.json b/packages/dashboard-v2/package.json index 80070815..53edfa12 100644 --- a/packages/dashboard-v2/package.json +++ b/packages/dashboard-v2/package.json @@ -22,6 +22,8 @@ "dependencies": { "@fontsource/sora": "^4.5.3", "@fontsource/source-sans-pro": "^4.5.3", + "@stripe/react-stripe-js": "^1.7.1", + "@stripe/stripe-js": "^1.27.0", "classnames": "^2.3.1", "copy-text-to-clipboard": "^3.0.1", "dayjs": "^1.10.8", @@ -33,7 +35,6 @@ "nanoid": "^3.3.1", "path-browserify": "^1.0.1", "postcss": "^8.4.6", - "pretty-bytes": "^6.0.0", "react": "^17.0.1", "react-dom": "^17.0.1", "react-dropzone": "^12.0.4", diff --git a/packages/dashboard-v2/src/components/Button/Button.js b/packages/dashboard-v2/src/components/Button/Button.js index 328d52cd..2a49244e 100644 --- a/packages/dashboard-v2/src/components/Button/Button.js +++ b/packages/dashboard-v2/src/components/Button/Button.js @@ -5,15 +5,25 @@ import styled from "styled-components"; /** * Primary UI component for user interaction */ -export const Button = styled.button.attrs(({ disabled, $primary, type }) => ({ - type, - 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, - }), -}))``; +export const Button = styled.button.attrs(({ as: polymorphicAs, disabled, $primary, type }) => { + // We want to default to type=button in most cases, but sometimes we use this component + // as a polymorphic one (i.e. for links), and then we should avoid setting `type` property, + // as it breaks styling in Safari. + const typeAttr = polymorphicAs && polymorphicAs !== "button" ? undefined : type; + + return { + type: typeAttr, + className: cn( + "px-6 py-2.5 inline-block 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 = { /** diff --git a/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js b/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js index d6df8506..f9bc101a 100644 --- a/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js +++ b/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js @@ -1,9 +1,9 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; -import prettyBytes from "pretty-bytes"; import { useUser } from "../../contexts/user"; import useActivePlan from "../../hooks/useActivePlan"; +import humanBytes from "../../lib/humanBytes"; import { ContainerLoadingIndicator } from "../LoadingIndicator"; import LatestPayment from "./LatestPayment"; @@ -33,7 +33,7 @@ const CurrentPlan = () => {

{activePlan.name}

{activePlan.price === 0 && activePlan.limits && ( -

{prettyBytes(activePlan.limits.storageLimit, { binary: true })} without paying a dime! 🎉

+

{humanBytes(activePlan.limits.storageLimit)} without paying a dime! 🎉

)} {activePlan.price !== 0 && (user.subscriptionCancelAtPeriodEnd ? ( diff --git a/packages/dashboard-v2/src/components/CurrentPlan/SuggestedPlan.js b/packages/dashboard-v2/src/components/CurrentPlan/SuggestedPlan.js index 21aa9b48..ed59c382 100644 --- a/packages/dashboard-v2/src/components/CurrentPlan/SuggestedPlan.js +++ b/packages/dashboard-v2/src/components/CurrentPlan/SuggestedPlan.js @@ -14,7 +14,7 @@ const SuggestedPlan = ({ plans, activePlan }) => {

Discover {nextPlan.name}

{nextPlan.description}

-
diff --git a/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js b/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js index f9dbbc36..cac40771 100644 --- a/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js +++ b/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js @@ -1,5 +1,4 @@ import { useEffect, useMemo, useState } from "react"; -import fileSize from "pretty-bytes"; import { Link } from "gatsby"; import cn from "classnames"; import useSWR from "swr"; @@ -10,6 +9,7 @@ import { ContainerLoadingIndicator } from "../LoadingIndicator"; import { GraphBar } from "./GraphBar"; import { UsageGraph } from "./UsageGraph"; +import humanBytes from "../../lib/humanBytes"; const useUsageData = () => { const { user } = useUser(); @@ -45,7 +45,7 @@ const useUsageData = () => { }; const size = (bytes) => { - const text = fileSize(bytes ?? 0, { maximumFractionDigits: 0, binary: true }); + const text = humanBytes(bytes ?? 0, { precision: 0 }); const [value, unit] = text.split(" "); return { @@ -99,7 +99,7 @@ export default function CurrentUsage() { Files { const isMediumScreenOrLarger = useMedia(`(min-width: ${theme.screens.md})`); - const { data, error } = useSWR(`user/${type}?pageSize=10`); + const [offset, setOffset] = useState(0); + const baseUrl = `user/${type}?pageSize=${PAGE_SIZE}`; + const { + data, + error, + mutate: refreshList, + } = useSWR(`${baseUrl}&offset=${offset}`, { + revalidateOnMount: true, + }); const items = useFormattedFilesData(data?.items || []); + const count = data?.count || 0; - const setFilter = (name, value) => console.log("filter", name, "set to", value); + // Next page preloading + const hasMoreRecords = data ? data.offset + data.pageSize < data.count : false; + const nextPageOffset = hasMoreRecords ? data.offset + data.pageSize : offset; + useSWR(`${baseUrl}&offset=${nextPageOffset}`); if (!items.length) { return (
- {/* TODO: proper error message */} {!data && !error && } {!data && error &&

An error occurred while loading this data.

} {data &&

No {type} found.

} @@ -32,42 +43,14 @@ const FileList = ({ type }) => { } return ( -
-
- } - onChange={console.log.bind(console)} - /> -
- setFilter("showSmallFiles", value)} className="mr-8"> - - Show small files - - -
- File type: - -
-
- Sort: - -
-
-
- {/* TODO: mobile view (it's not tabular) */} - {isMediumScreenOrLarger ? : "Mobile view"} -
+ <> + {isMediumScreenOrLarger ? ( + + ) : ( + + )} + + ); }; diff --git a/packages/dashboard-v2/src/components/FileList/FileTable.js b/packages/dashboard-v2/src/components/FileList/FileTable.js index c2f133d6..88477648 100644 --- a/packages/dashboard-v2/src/components/FileList/FileTable.js +++ b/packages/dashboard-v2/src/components/FileList/FileTable.js @@ -2,110 +2,78 @@ import { CogIcon, ShareIcon } from "../Icons"; import { PopoverMenu } from "../PopoverMenu/PopoverMenu"; import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from "../Table"; import { CopyButton } from "../CopyButton"; +import { useSkylinkOptions } from "./useSkylinkOptions"; +import { useSkylinkSharing } from "./useSkylinkSharing"; -const buildShareMenu = (item) => { - return [ - { - label: "Facebook", - callback: () => { - console.info("share to Facebook", item); - }, - }, - { - label: "Twitter", - callback: () => { - console.info("share to Twitter", item); - }, - }, - { - label: "Discord", - callback: () => { - console.info("share to Discord", item); - }, - }, - ]; -}; +const SkylinkOptionsMenu = ({ skylink, onUpdated }) => { + const { inProgress, options } = useSkylinkOptions({ skylink, onUpdated }); -const buildOptionsMenu = (item) => { - return [ - { - label: "Preview", - callback: () => { - console.info("preview", item); - }, - }, - { - label: "Download", - callback: () => { - console.info("download", item); - }, - }, - { - label: "Unpin", - callback: () => { - console.info("unpin", item); - }, - }, - { - label: "Report", - callback: () => { - console.info("report", item); - }, - }, - ]; -}; - -export default function FileTable({ items }) { return ( - - - - Name - Type - - Size - - Uploaded - Skylink - Activity - - - - {items.map((item) => { - const { id, name, type, size, date, skylink } = item; + + + + ); +}; - return ( - - {name} - {type} - - {size} - - {date} - -
- - {skylink} -
-
- -
- - - - - - -
-
-
- ); - })} -
-
+const SkylinkSharingMenu = ({ skylink }) => { + const { options } = useSkylinkSharing(skylink); + + return ( + + + + ); +}; + +export default function FileTable({ items, onUpdated }) { + return ( +
+ + + + Name + Type + + Size + + Uploaded + Skylink + Activity + + + + {items.map((item) => { + const { id, name, type, size, date, skylink } = item; + + return ( + + {name} + {type} + + {size} + + {date} + +
+ + {skylink} +
+
+ +
+ + +
+
+
+ ); + })} +
+
+
); } diff --git a/packages/dashboard-v2/src/components/FileList/MobileFileList.js b/packages/dashboard-v2/src/components/FileList/MobileFileList.js new file mode 100644 index 00000000..bd11aa10 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/MobileFileList.js @@ -0,0 +1,84 @@ +import { useState } from "react"; +import cn from "classnames"; + +import { ChevronDownIcon } from "../Icons"; +import { useSkylinkSharing } from "./useSkylinkSharing"; +import { ContainerLoadingIndicator } from "../LoadingIndicator"; +import { useSkylinkOptions } from "./useSkylinkOptions"; + +const SharingMenu = ({ skylink }) => { + const { options } = useSkylinkSharing(skylink); + + return ( +
+ {options.map(({ label, callback }, index) => ( + + ))} +
+ ); +}; + +const OptionsMenu = ({ skylink, onUpdated }) => { + const { inProgress, options } = useSkylinkOptions({ skylink, onUpdated }); + + return ( +
+
+ {options.map(({ label, callback }, index) => ( + + ))} +
+ {inProgress && ( + + )} +
+ ); +}; + +const ListItem = ({ item, onUpdated }) => { + const { name, type, size, date, skylink } = item; + const [open, setOpen] = useState(false); + + const toggle = () => setOpen((open) => !open); + + return ( +
+
+
+
{name}
+
+ {type} + {size} + {date} +
+
+ +
+
+ + +
+
+ ); +}; + +export const MobileFileList = ({ items, onUpdated }) => { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +}; diff --git a/packages/dashboard-v2/src/components/FileList/Pagination.js b/packages/dashboard-v2/src/components/FileList/Pagination.js new file mode 100644 index 00000000..248c03a3 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/Pagination.js @@ -0,0 +1,32 @@ +import { Button } from "../Button"; + +export const Pagination = ({ count, offset, setOffset, pageSize }) => { + const start = count ? offset + 1 : 0; + const end = offset + pageSize > count ? count : offset + pageSize; + + const showPaginationButtons = offset > 0 || count > end; + + return ( + + ); +}; diff --git a/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js index 87bf1af6..10639458 100644 --- a/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js +++ b/packages/dashboard-v2/src/components/FileList/useFormattedFilesData.js @@ -1,7 +1,7 @@ import { useMemo } from "react"; -import prettyBytes from "pretty-bytes"; import dayjs from "dayjs"; import { DATE_FORMAT } from "../../lib/config"; +import humanBytes from "../../lib/humanBytes"; const parseFileName = (fileName) => { const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity; @@ -16,7 +16,7 @@ const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest return { ...rest, date, - size: prettyBytes(size), + size: humanBytes(size, { precision: 2 }), type, name, }; diff --git a/packages/dashboard-v2/src/components/FileList/useSkylinkOptions.js b/packages/dashboard-v2/src/components/FileList/useSkylinkOptions.js new file mode 100644 index 00000000..ad116cd4 --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/useSkylinkOptions.js @@ -0,0 +1,35 @@ +import { useMemo, useState } from "react"; + +import accountsService from "../../services/accountsService"; +import skynetClient from "../../services/skynetClient"; + +export const useSkylinkOptions = ({ skylink, onUpdated }) => { + const [inProgress, setInProgress] = useState(false); + + const options = useMemo( + () => [ + { + label: "Preview", + callback: async () => window.open(await skynetClient.getSkylinkUrl(skylink)), + }, + { + label: "Download", + callback: () => skynetClient.downloadFile(skylink), + }, + { + label: "Unpin", + callback: async () => { + setInProgress(true); + await accountsService.delete(`user/uploads/${skylink}`); + await onUpdated(); // No need to setInProgress(false), since at this point this hook should already be unmounted + }, + }, + ], + [skylink, onUpdated] + ); + + return { + inProgress, + options, + }; +}; diff --git a/packages/dashboard-v2/src/components/FileList/useSkylinkSharing.js b/packages/dashboard-v2/src/components/FileList/useSkylinkSharing.js new file mode 100644 index 00000000..0b09302b --- /dev/null +++ b/packages/dashboard-v2/src/components/FileList/useSkylinkSharing.js @@ -0,0 +1,40 @@ +import { useEffect, useMemo, useState } from "react"; +import copy from "copy-text-to-clipboard"; + +import skynetClient from "../../services/skynetClient"; + +const COPY_LINK_LABEL = "Copy link"; + +export const useSkylinkSharing = (skylink) => { + const [copied, setCopied] = useState(false); + const [copyLabel, setCopyLabel] = useState(COPY_LINK_LABEL); + + useEffect(() => { + if (copied) { + setCopyLabel("Copied!"); + + const timeout = setTimeout(() => setCopied(false), 1500); + + return () => clearTimeout(timeout); + } else { + setCopyLabel(COPY_LINK_LABEL); + } + }, [copied]); + + const options = useMemo( + () => [ + { + label: copyLabel, + callback: async () => { + setCopied(true); + copy(await skynetClient.getSkylinkUrl(skylink)); + }, + }, + ], + [skylink, copyLabel] + ); + + return { + options, + }; +}; diff --git a/packages/dashboard-v2/src/components/Metadata/Metadata.js b/packages/dashboard-v2/src/components/Metadata/Metadata.js index 5bb98330..0e1dd2b6 100644 --- a/packages/dashboard-v2/src/components/Metadata/Metadata.js +++ b/packages/dashboard-v2/src/components/Metadata/Metadata.js @@ -1,6 +1,13 @@ import { Helmet } from "react-helmet"; import { graphql, useStaticQuery } from "gatsby"; +import favicon from "../../../static/favicon.ico"; +import favicon16 from "../../../static/favicon-16x16.png"; +import favicon32 from "../../../static/favicon-32x32.png"; +import appleIcon144 from "../../../static/apple-touch-icon-144x144.png"; +import appleIcon152 from "../../../static/apple-touch-icon-152x152.png"; +import msTileIcon from "../../../static/mstile-144x144.png"; + export const Metadata = ({ children }) => { const { site } = useStaticQuery( graphql` @@ -18,7 +25,15 @@ export const Metadata = ({ children }) => { return ( - + + + + + + + + + {children} diff --git a/packages/dashboard-v2/src/components/Panel/Panel.js b/packages/dashboard-v2/src/components/Panel/Panel.js index 27551ecd..fe50cc89 100644 --- a/packages/dashboard-v2/src/components/Panel/Panel.js +++ b/packages/dashboard-v2/src/components/Panel/Panel.js @@ -14,8 +14,8 @@ const PanelTitle = styled.h6.attrs({ * * These additional props will be rendered onto the panel's body element. */ -export const Panel = ({ title, ...props }) => ( -
+export const Panel = ({ title, wrapperClassName, ...props }) => ( +
{title && {title}}
@@ -26,8 +26,13 @@ Panel.propTypes = { * Label of the panel */ title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), + /** + * CSS class to be applied to the external container + */ + wrapperClassName: PropTypes.string, }; Panel.defaultProps = { title: "", + wrapperClassName: "", }; diff --git a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js index 1826cd92..dd5a8597 100644 --- a/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js +++ b/packages/dashboard-v2/src/components/PopoverMenu/PopoverMenu.js @@ -2,6 +2,9 @@ import { Children, cloneElement, useRef, useState } from "react"; import PropTypes from "prop-types"; import { useClickAway } from "react-use"; import styled, { css, keyframes } from "styled-components"; +import cn from "classnames"; + +import { ContainerLoadingIndicator } from "../LoadingIndicator"; const dropDown = keyframes` 0% { @@ -41,15 +44,15 @@ const Option = styled.li.attrs({ hover:before:content-['']`, })``; -export const PopoverMenu = ({ options, children, openClassName, ...props }) => { +export const PopoverMenu = ({ options, children, openClassName, inProgress, ...props }) => { const containerRef = useRef(); const [open, setOpen] = useState(false); useClickAway(containerRef, () => setOpen(false)); - const handleChoice = (callback) => () => { + const handleChoice = (callback) => async () => { + await callback(); setOpen(false); - callback(); }; return ( @@ -62,11 +65,16 @@ export const PopoverMenu = ({ options, children, openClassName, ...props }) => { )} {open && ( - {options.map(({ label, callback }) => ( - - ))} +
+ {options.map(({ label, callback }) => ( + + ))} + {inProgress && ( + + )} +
)} @@ -87,4 +95,9 @@ PopoverMenu.propTypes = { callback: PropTypes.func.isRequired, }) ).isRequired, + + /** + * If true, a loading icon will be displayed to signal an async action is taking place. + */ + inProgress: PropTypes.bool, }; diff --git a/packages/dashboard-v2/src/components/Slider/Slide.js b/packages/dashboard-v2/src/components/Slider/Slide.js index 42d354ff..eafbc563 100644 --- a/packages/dashboard-v2/src/components/Slider/Slide.js +++ b/packages/dashboard-v2/src/components/Slider/Slide.js @@ -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 select-none"}`, + className: `slider-slide transition-opacity h-full ${isVisible ? "" : "opacity-50 cursor-pointer select-none"}`, }))``; Slide.propTypes = { diff --git a/packages/dashboard-v2/src/components/Slider/Slider.js b/packages/dashboard-v2/src/components/Slider/Slider.js index 33c3bc2a..5b902c58 100644 --- a/packages/dashboard-v2/src/components/Slider/Slider.js +++ b/packages/dashboard-v2/src/components/Slider/Slider.js @@ -62,7 +62,7 @@ const Slider = ({ slides, breakpoints, scrollerClassName, className }) => { const isVisible = index >= activeIndex && index < activeIndex + visibleSlides; return ( -
+
file.webkitRelativePath || file.path || file.name; @@ -88,7 +88,7 @@ export default function UploaderItem({ onUploadStateChange, upload }) {
{upload.status === "uploading" && ( - Uploading {bytes(upload.file.size * upload.progress)} of {bytes(upload.file.size)} + Uploading {humanBytes(upload.file.size * upload.progress)} of {humanBytes(upload.file.size)} )} {upload.status === "enqueued" && Upload in queue, please wait} diff --git a/packages/dashboard-v2/src/components/Uploader/buildUploadErrorMessage.js b/packages/dashboard-v2/src/components/Uploader/buildUploadErrorMessage.js index c41cd717..11cfed4b 100644 --- a/packages/dashboard-v2/src/components/Uploader/buildUploadErrorMessage.js +++ b/packages/dashboard-v2/src/components/Uploader/buildUploadErrorMessage.js @@ -1,5 +1,5 @@ import { getReasonPhrase } from "http-status-codes"; -import bytes from "pretty-bytes"; +import humanBytes from "../../lib/humanBytes"; export default function buildUploadErrorMessage(error) { // The request was made and the server responded with a status code that falls out of the range of 2xx @@ -29,7 +29,7 @@ export default function buildUploadErrorMessage(error) { const matchTusMaxFileSizeError = error.message.match(/upload exceeds maximum size: \d+ > (?\d+)/); if (matchTusMaxFileSizeError) { - return `File exceeds size limit of ${bytes(parseInt(matchTusMaxFileSizeError.groups.limit, 10))}`; + return `File exceeds size limit of ${humanBytes(matchTusMaxFileSizeError.groups.limit, { precision: 0 })}`; } // TODO: We should add a note "our team has been notified" and have some kind of notification with this error. diff --git a/packages/dashboard-v2/src/hooks/useUpgradeRedirect.js b/packages/dashboard-v2/src/hooks/useUpgradeRedirect.js index 54efd956..037db65f 100644 --- a/packages/dashboard-v2/src/hooks/useUpgradeRedirect.js +++ b/packages/dashboard-v2/src/hooks/useUpgradeRedirect.js @@ -17,7 +17,7 @@ export default function useUpgradeRedirect() { if (isDataLoaded) { if (settings.isSubscriptionRequired && !hasPaidSubscription) { - navigate("/upgrade"); + navigate("/payments"); } else { setVerifyingSubscription(false); } diff --git a/packages/dashboard-v2/src/layouts/UserSettingsLayout.js b/packages/dashboard-v2/src/layouts/UserSettingsLayout.js index f008ee40..9bda3f96 100644 --- a/packages/dashboard-v2/src/layouts/UserSettingsLayout.js +++ b/packages/dashboard-v2/src/layouts/UserSettingsLayout.js @@ -10,12 +10,14 @@ const Sidebar = () => ( Account + {/* Notifications Export + */} Developer settings diff --git a/packages/dashboard-v2/src/lib/humanBytes.js b/packages/dashboard-v2/src/lib/humanBytes.js new file mode 100644 index 00000000..ac1fbfa2 --- /dev/null +++ b/packages/dashboard-v2/src/lib/humanBytes.js @@ -0,0 +1,21 @@ +const UNITS = ["B", "kB", "MB", "GB", "TB", "PB", "EB"]; +const BASE = 1024; +const DEFAULT_OPTIONS = { precision: 1 }; + +export default function humanBytes(bytes, { precision } = DEFAULT_OPTIONS) { + if (!Number.isFinite(bytes) || bytes < 0) { + throw new TypeError(`Expected a finite, positive number. Received: ${typeof bytes}: ${bytes}`); + } + + let value = bytes; + let unitIndex = 0; + + while (value >= BASE) { + value /= BASE; + unitIndex += 1; + } + + const localizedValue = value.toLocaleString(undefined, { maximumFractionDigits: precision }); + + return `${localizedValue} ${UNITS[unitIndex]}`; +} diff --git a/packages/dashboard-v2/src/pages/404.js b/packages/dashboard-v2/src/pages/404.js index fd99104f..36c9165a 100644 --- a/packages/dashboard-v2/src/pages/404.js +++ b/packages/dashboard-v2/src/pages/404.js @@ -1,54 +1,27 @@ import * as React from "react"; -import { Link } from "gatsby"; -// styles -const pageStyles = { - color: "#232129", - padding: "96px", - fontFamily: "-apple-system, Roboto, sans-serif, serif", -}; -const headingStyles = { - marginTop: 0, - marginBottom: 64, - maxWidth: 320, -}; +import DashboardLayout from "../layouts/DashboardLayout"; -const paragraphStyles = { - marginBottom: 48, -}; -const codeStyles = { - color: "#8A6534", - padding: 4, - backgroundColor: "#FFF4DB", - fontSize: "1.25rem", - borderRadius: 4, -}; +import { Metadata } from "../components/Metadata"; +import HighlightedLink from "../components/HighlightedLink"; -// markup const NotFoundPage = () => { return ( -
- Not found -

Page not found

-

- Sorry{" "} - - 😔 - {" "} - we couldn’t find what you were looking for. -
- {process.env.NODE_ENV === "development" ? ( - <> -
- Try creating a page in src/pages/. -
- - ) : null} -
- Go home. -

-
+
+ + Not found + +
+

Oops! 😔

+

Whatever you're looking for is not here.

+

+ Would you like to go back to homepage? +

+
+
); }; +NotFoundPage.Layout = DashboardLayout; + export default NotFoundPage; diff --git a/packages/dashboard-v2/src/pages/auth/registration.js b/packages/dashboard-v2/src/pages/auth/registration.js index 5764ad6a..899abe50 100644 --- a/packages/dashboard-v2/src/pages/auth/registration.js +++ b/packages/dashboard-v2/src/pages/auth/registration.js @@ -1,5 +1,4 @@ import { useCallback, useState } from "react"; -import bytes from "pretty-bytes"; import AuthLayout from "../../layouts/AuthLayout"; @@ -10,12 +9,13 @@ import { usePortalSettings } from "../../contexts/portal-settings"; import { PlansProvider, usePlans } from "../../contexts/plans"; import { Metadata } from "../../components/Metadata"; import { useUser } from "../../contexts/user"; +import humanBytes from "../../lib/humanBytes"; const FreePortalHeader = () => { const { plans } = usePlans(); const freePlan = plans.find(({ price }) => price === 0); - const freeStorage = freePlan?.limits ? bytes(freePlan.limits?.storageLimit, { binary: true }) : null; + const freeStorage = freePlan?.limits ? humanBytes(freePlan.limits?.storageLimit, { binary: true }) : null; return (
diff --git a/packages/dashboard-v2/src/pages/files.js b/packages/dashboard-v2/src/pages/files.js index be856d4a..b927c09f 100644 --- a/packages/dashboard-v2/src/pages/files.js +++ b/packages/dashboard-v2/src/pages/files.js @@ -12,7 +12,7 @@ const FilesPage = () => { Files - + diff --git a/packages/dashboard-v2/src/pages/payments.js b/packages/dashboard-v2/src/pages/payments.js index c720216c..adf2303b 100644 --- a/packages/dashboard-v2/src/pages/payments.js +++ b/packages/dashboard-v2/src/pages/payments.js @@ -1,11 +1,232 @@ import * as React from "react"; +import styled from "styled-components"; +import { useStripe } from "@stripe/react-stripe-js"; +import cn from "classnames"; +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"; +import { usePortalSettings } from "../contexts/portal-settings"; +import { Alert } from "../components/Alert"; +import HighlightedLink from "../components/HighlightedLink"; +import { Metadata } from "../components/Metadata"; +import accountsService from "../services/accountsService"; +import humanBytes from "../lib/humanBytes"; +import { Modal } from "../components/Modal"; -const PaymentsPage = () => { - return <>PAYMENTS HISTORY; +const PAID_PORTAL_BREAKPOINTS = [ + { + name: "lg", + scrollable: true, + visibleSlides: 3, + }, + { + name: "sm", + scrollable: true, + visibleSlides: 2, + }, + { + scrollable: true, + visibleSlides: 1, + }, +]; + +const FREE_PORTAL_BREAKPOINTS = [ + { + name: "xl", + scrollable: true, + visibleSlides: 4, + }, + ...PAID_PORTAL_BREAKPOINTS, +]; + +const PlanSummaryItem = ({ children }) => ( +
  • + +
    {children}
    +
  • +); + +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 }) => ( +
    +

    + $ + {price} +

    +

    per month

    +
    +); + +const bandwidth = (value) => `${humanBytes(value, { bits: true })}/s`; + +const storage = (value) => humanBytes(value, { binary: true }); + +const localizedNumber = (value) => value.toLocaleString(); + +const PlansSlider = () => { + const { user, error: userError } = useUser(); + const { plans, loading, activePlan, error: plansError } = useActivePlan(user); + const { settings } = usePortalSettings(); + const [showPaymentError, setShowPaymentError] = React.useState(false); + const stripe = useStripe(); + // This will be the base plan that we compare upload/download speeds against. + // On will either be the user's active plan or lowest of available tiers. + const basePlan = activePlan || plans[0]; + + if (userError || plansError) { + return ( +
    +

    Oooops!

    +

    Something went wrong, please try again later.

    +
    + ); + } + + const handleSubscribe = async (selectedPlan) => { + try { + const { sessionId } = await accountsService + .post("stripe/checkout", { + json: { + price: selectedPlan.stripe, + }, + }) + .json(); + await stripe.redirectToCheckout({ sessionId }); + } catch (error) { + setShowPaymentError(true); + console.error(error); + } + }; + + return ( +
    + + Payments + + {settings.isSubscriptionRequired && !activePlan && ( + +

    This Skynet portal requires a paid subscription.

    +

    + If you're not ready for that yet, you can use your account on{" "} + + SkynetFree.net + {" "} + to store up to 100GB for free. +

    +
    + )} + {!loading && ( + { + const isHigherThanCurrent = plan.tier > activePlan?.tier; + const isCurrentPlanPaid = activePlan?.tier > 1; + const isCurrent = plan.tier === activePlan?.tier; + const isLower = plan.tier < activePlan?.tier; + const speed = plan.limits.uploadBandwidth; + const currentSpeed = basePlan?.limits?.uploadBandwidth; + const speedChange = speed > currentSpeed ? speed / currentSpeed : currentSpeed / speed; + const hasActivePlan = Boolean(activePlan); + + return ( + + {isCurrent && ( +
    + + Current plan + +
    + )} +

    {plan.name}

    + {plan.description} + + +
    + {(!hasActivePlan || isHigherThanCurrent) && + (isCurrentPlanPaid ? ( + + ) : ( + + ))} + {isCurrent && } + {isLower && ( + + )} +
    + {plan.limits && ( +
      + + Pin up to {storage(plan.limits.storageLimit)} on decentralized storage + + + Support for up to {localizedNumber(plan.limits.maxNumberUploads)} files + + + {speed === currentSpeed + ? `${bandwidth(plan.limits.uploadBandwidth)} upload and ${bandwidth( + plan.limits.downloadBandwidth + )} download` + : `${speedChange}X ${ + speed > currentSpeed ? "faster" : "slower" + } upload and download speeds (${bandwidth(plan.limits.uploadBandwidth)} and ${bandwidth( + plan.limits.downloadBandwidth + )})`} + + + {plan.limits.maxUploadSize === plan.limits.storageLimit + ? "No limit to file upload size" + : `Upload files up to ${storage(plan.limits.maxUploadSize)}`} + +
    + )} +
    + ); + })} + breakpoints={settings.isSubscriptionRequired ? PAID_PORTAL_BREAKPOINTS : FREE_PORTAL_BREAKPOINTS} + className="px-8 sm:px-4 md:px-0 lg:px-0 mt-10" + /> + )} + {showPaymentError && ( + setShowPaymentError(false)}> +

    Oops! 😔

    +

    There was an error contacting our payments provider

    +

    Please try again later

    +
    + )} +
    + ); }; +const PaymentsPage = () => ( + + + +); + PaymentsPage.Layout = DashboardLayout; export default PaymentsPage; diff --git a/packages/dashboard-v2/src/pages/upgrade.js b/packages/dashboard-v2/src/pages/upgrade.js deleted file mode 100644 index 9f69487e..00000000 --- a/packages/dashboard-v2/src/pages/upgrade.js +++ /dev/null @@ -1,157 +0,0 @@ -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"; -import { usePortalSettings } from "../contexts/portal-settings"; -import { Alert } from "../components/Alert"; -import HighlightedLink from "../components/HighlightedLink"; -import { Metadata } from "../components/Metadata"; - -const PAID_PORTAL_BREAKPOINTS = [ - { - name: "lg", - scrollable: true, - visibleSlides: 3, - }, - { - name: "sm", - scrollable: true, - visibleSlides: 2, - }, - { - scrollable: true, - visibleSlides: 1, - }, -]; - -const FREE_PORTAL_BREAKPOINTS = [ - { - name: "xl", - scrollable: true, - visibleSlides: 4, - }, - ...PAID_PORTAL_BREAKPOINTS, -]; - -const PlanSummaryItem = ({ children }) => ( -
  • - -
    {children}
    -
  • -); - -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 }) => ( -
    -

    - $ - {price} -

    -

    per month

    -
    -); - -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); - const { settings } = usePortalSettings(); - - if (userError || plansError) { - return ( -
    -

    Oooops!

    -

    Something went wrong, please try again later.

    -
    - ); - } - - return ( -
    - - Upgrade - - {settings.isSubscriptionRequired && !activePlan && ( - -

    This Skynet portal requires a paid subscription.

    -

    - If you're not ready for that yet, you can use your account on{" "} - - SkynetFree.net - {" "} - to store up to 100GB for free. -

    -
    - )} - {!loading && ( - { - const isHigherThanCurrent = plan.tier > activePlan?.tier; - const isCurrent = plan.tier === activePlan?.tier; - - return ( - -

    {plan.name}

    - {plan.description} - - -
    - -
    - {plan.limits && ( -
      - - Pin up to {storage(plan.limits.storageLimit)} of censorship-resistant storage - - - Support for up to {localizedNumber(plan.limits.maxNumberUploads)} files - - {bandwidth(plan.limits.uploadBandwidth)} upload bandwidth - {bandwidth(plan.limits.downloadBandwidth)} download bandwidth -
    - )} -
    - ); - })} - breakpoints={settings.isSubscriptionRequired ? PAID_PORTAL_BREAKPOINTS : FREE_PORTAL_BREAKPOINTS} - className="px-8 sm:px-4 md:px-0 lg:px-0" - /> - )} -
    - ); -}; - -const UpgradePage = () => ( - - - -); - -UpgradePage.Layout = DashboardLayout; - -export default UpgradePage; diff --git a/packages/dashboard-v2/static/apple-touch-icon-144x144.png b/packages/dashboard-v2/static/apple-touch-icon-144x144.png new file mode 100644 index 00000000..ec40c3e0 Binary files /dev/null and b/packages/dashboard-v2/static/apple-touch-icon-144x144.png differ diff --git a/packages/dashboard-v2/static/apple-touch-icon-152x152.png b/packages/dashboard-v2/static/apple-touch-icon-152x152.png new file mode 100644 index 00000000..f65a5ddb Binary files /dev/null and b/packages/dashboard-v2/static/apple-touch-icon-152x152.png differ diff --git a/packages/dashboard-v2/static/favicon-16x16.png b/packages/dashboard-v2/static/favicon-16x16.png new file mode 100644 index 00000000..10935441 Binary files /dev/null and b/packages/dashboard-v2/static/favicon-16x16.png differ diff --git a/packages/dashboard-v2/static/favicon-32x32.png b/packages/dashboard-v2/static/favicon-32x32.png new file mode 100644 index 00000000..99ee3c1c Binary files /dev/null and b/packages/dashboard-v2/static/favicon-32x32.png differ diff --git a/packages/dashboard-v2/static/favicon.ico b/packages/dashboard-v2/static/favicon.ico index 9229fbf7..5c4ae303 100644 Binary files a/packages/dashboard-v2/static/favicon.ico and b/packages/dashboard-v2/static/favicon.ico differ diff --git a/packages/dashboard-v2/static/mstile-144x144.png b/packages/dashboard-v2/static/mstile-144x144.png new file mode 100644 index 00000000..ec40c3e0 Binary files /dev/null and b/packages/dashboard-v2/static/mstile-144x144.png differ diff --git a/packages/dashboard-v2/tailwind.config.js b/packages/dashboard-v2/tailwind.config.js index 636cd40e..141d689e 100644 --- a/packages/dashboard-v2/tailwind.config.js +++ b/packages/dashboard-v2/tailwind.config.js @@ -29,6 +29,7 @@ module.exports = { textColor: (theme) => ({ ...theme("colors"), ...colors }), placeholderColor: (theme) => ({ ...theme("colors"), ...colors }), outlineColor: (theme) => ({ ...theme("colors"), ...colors }), + divideColor: (theme) => ({ ...theme("colors"), ...colors }), extend: { fontFamily: { sans: ["Sora", ...defaultTheme.fontFamily.sans], diff --git a/packages/dashboard-v2/yarn.lock b/packages/dashboard-v2/yarn.lock index 0ac39653..ae43c2db 100644 --- a/packages/dashboard-v2/yarn.lock +++ b/packages/dashboard-v2/yarn.lock @@ -3107,6 +3107,18 @@ resolve-from "^5.0.0" store2 "^2.12.0" +"@stripe/react-stripe-js@^1.7.1": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-1.7.1.tgz#6e1db8f4a0eaf2193b153173d4aa7c38b681310d" + integrity sha512-GiUPoMo0xVvmpRD6JR9JAhAZ0W3ZpnYZNi0KE+91+tzrSFVpChKZbeSsJ5InlZhHFk9NckJCt1wOYBTqNsvt3A== + dependencies: + prop-types "^15.7.2" + +"@stripe/stripe-js@^1.27.0": + version "1.27.0" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.27.0.tgz#ab0c82fa89fd40260de4414f69868b769e810550" + integrity sha512-SEiybUBu+tlsFKuzdFFydxxjkbrdzHo0tz/naYC5Dt9or/Ux2gcKJBPYQ4RmqQCNHFxgyNj6UYsclywwhe2inQ== + "@szmarczak/http-timer@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421" @@ -12696,11 +12708,6 @@ pretty-bytes@^5.4.1: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== -pretty-bytes@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.0.0.tgz#928be2ad1f51a2e336add8ba764739f9776a8140" - integrity sha512-6UqkYefdogmzqAZWzJ7laYeJnaXDy2/J+ZqiiMtS7t7OfpXWTlaeGMwX8U6EFvPV/YWWEKRkS8hKS4k60WHTOg== - pretty-error@^2.1.1, pretty-error@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.2.tgz#be89f82d81b1c86ec8fdfbc385045882727f93b6"