diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c4d4429d..7b7d3f12 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,18 +20,10 @@ updates: directory: "/packages/website" schedule: interval: weekly - - package-ecosystem: docker - directory: "/docker/accounts" - schedule: - interval: weekly - package-ecosystem: docker directory: "/docker/caddy" schedule: interval: weekly - - package-ecosystem: docker - directory: "/docker/handshake" - schedule: - interval: weekly - package-ecosystem: docker directory: "/docker/nginx" schedule: diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index dc6058ac..9242c138 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -43,5 +43,7 @@ jobs: uses: skynetlabs/deploy-to-skynet-action@v2 with: upload-dir: packages/website/public + portal-url: https://skynetpro.net + skynet-jwt: ${{ secrets.SKYNET_JWT }} github-token: ${{ secrets.GITHUB_TOKEN }} registry-seed: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.WEBSITE_REGISTRY_SEED || '' }} diff --git a/docker-compose.accounts.yml b/docker-compose.accounts.yml index 7c3ed921..a3941f6b 100644 --- a/docker-compose.accounts.yml +++ b/docker-compose.accounts.yml @@ -20,7 +20,11 @@ services: - ACCOUNTS_LIMIT_ACCESS=${ACCOUNTS_LIMIT_ACCESS:-authenticated} # default to authenticated access only accounts: - image: skynetlabs/skynet-accounts + build: + context: ./docker/accounts + dockerfile: Dockerfile + args: + branch: main container_name: accounts restart: unless-stopped logging: *default-logging diff --git a/docker/accounts/Dockerfile b/docker/accounts/Dockerfile new file mode 100644 index 00000000..5cbf359a --- /dev/null +++ b/docker/accounts/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.16.7 +LABEL maintainer="SkynetLabs " + +ENV GOOS linux +ENV GOARCH amd64 + +ARG branch=main + +WORKDIR /root + +RUN git clone --single-branch --branch ${branch} https://github.com/SkynetLabs/skynet-accounts.git && \ + cd skynet-accounts && \ + go mod download && \ + make release + +ENV SKYNET_DB_HOST="localhost" +ENV SKYNET_DB_PORT="27017" +ENV SKYNET_DB_USER="username" +ENV SKYNET_DB_PASS="password" +ENV SKYNET_ACCOUNTS_PORT=3000 + +ENTRYPOINT ["skynet-accounts"] diff --git a/docker/caddy/caddy.json.template b/docker/caddy/caddy.json.template index 0dadbb9e..a133f0cd 100644 --- a/docker/caddy/caddy.json.template +++ b/docker/caddy/caddy.json.template @@ -25,8 +25,7 @@ "dns": { "provider": { "name": "route53" - }, - "ttl": "30m" + } } } } diff --git a/docker/nginx/conf.d.templates/server.account.conf b/docker/nginx/conf.d.templates/server.account.conf index 0de88c03..c83cc208 100644 --- a/docker/nginx/conf.d.templates/server.account.conf +++ b/docker/nginx/conf.d.templates/server.account.conf @@ -9,6 +9,15 @@ server { server_name account.{{PORTAL_DOMAIN}}; # example: account.siasky.net + set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { + -- fall back to portal domain if server domain is not defined + if "{{SERVER_DOMAIN}}" == "" then + return "{{PORTAL_DOMAIN}}" + end + return "{{SERVER_DOMAIN}}" + } + ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{PORTAL_DOMAIN}}/wildcard_.{{PORTAL_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{PORTAL_DOMAIN}}/wildcard_.{{PORTAL_DOMAIN}}.key; @@ -31,6 +40,9 @@ ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{SERVER_DOMAIN}}/wildcard_.{{SERVER_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{SERVER_DOMAIN}}/wildcard_.{{SERVER_DOMAIN}}.key; + set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" } + include /etc/nginx/conf.d/server/server.account; set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") } diff --git a/docker/nginx/conf.d.templates/server.api.conf b/docker/nginx/conf.d.templates/server.api.conf index 1adb58c1..0421ed29 100644 --- a/docker/nginx/conf.d.templates/server.api.conf +++ b/docker/nginx/conf.d.templates/server.api.conf @@ -8,6 +8,15 @@ server { server { server_name {{PORTAL_DOMAIN}}; # example: siasky.net + set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { + -- fall back to portal domain if server domain is not defined + if "{{SERVER_DOMAIN}}" == "" then + return "{{PORTAL_DOMAIN}}" + end + return "{{SERVER_DOMAIN}}" + } + ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{{PORTAL_DOMAIN}}/{{PORTAL_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{{PORTAL_DOMAIN}}/{{PORTAL_DOMAIN}}.key; @@ -30,6 +39,9 @@ server { ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{{SERVER_DOMAIN}}/{{SERVER_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/{{SERVER_DOMAIN}}/{{SERVER_DOMAIN}}.key; + set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" } + include /etc/nginx/conf.d/server/server.api; set_by_lua_block $server_alias { return string.match("{{SERVER_DOMAIN}}", "^([^.]+)") } diff --git a/docker/nginx/conf.d.templates/server.hns.conf b/docker/nginx/conf.d.templates/server.hns.conf index a40427f1..bbd24d60 100644 --- a/docker/nginx/conf.d.templates/server.hns.conf +++ b/docker/nginx/conf.d.templates/server.hns.conf @@ -8,6 +8,15 @@ server { server { server_name *.hns.{{PORTAL_DOMAIN}}; # example: *.hns.siasky.net + set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { + -- fall back to portal domain if server domain is not defined + if "{{SERVER_DOMAIN}}" == "" then + return "{{PORTAL_DOMAIN}}" + end + return "{{SERVER_DOMAIN}}" + } + ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.hns.{{PORTAL_DOMAIN}}/wildcard_.hns.{{PORTAL_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.hns.{{PORTAL_DOMAIN}}/wildcard_.hns.{{PORTAL_DOMAIN}}.key; @@ -30,6 +39,9 @@ server { ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.hns.{{SERVER_DOMAIN}}/wildcard_.hns.{{SERVER_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.hns.{{SERVER_DOMAIN}}/wildcard_.hns.{{SERVER_DOMAIN}}.key; + + set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" } proxy_set_header Host {{SERVER_DOMAIN}}; include /etc/nginx/conf.d/server/server.hns; diff --git a/docker/nginx/conf.d.templates/server.skylink.conf b/docker/nginx/conf.d.templates/server.skylink.conf index 0cfc2027..792ad901 100644 --- a/docker/nginx/conf.d.templates/server.skylink.conf +++ b/docker/nginx/conf.d.templates/server.skylink.conf @@ -7,6 +7,15 @@ server { server { server_name *.{{PORTAL_DOMAIN}}; # example: *.siasky.net + + set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { + -- fall back to portal domain if server domain is not defined + if "{{SERVER_DOMAIN}}" == "" then + return "{{PORTAL_DOMAIN}}" + end + return "{{SERVER_DOMAIN}}" + } ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{PORTAL_DOMAIN}}/wildcard_.{{PORTAL_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{PORTAL_DOMAIN}}/wildcard_.{{PORTAL_DOMAIN}}.key; @@ -26,6 +35,9 @@ server { server { server_name *.{{SERVER_DOMAIN}}; # example: *.eu-ger-1.siasky.net + + set_by_lua_block $skynet_portal_domain { return "{{SERVER_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { return "{{SERVER_DOMAIN}}" } ssl_certificate /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{SERVER_DOMAIN}}/wildcard_.{{SERVER_DOMAIN}}.crt; ssl_certificate_key /data/caddy/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.{{SERVER_DOMAIN}}/wildcard_.{{SERVER_DOMAIN}}.key; diff --git a/docker/nginx/conf.d/include/location-hns b/docker/nginx/conf.d/include/location-hns index b0c7322d..62ff9729 100644 --- a/docker/nginx/conf.d/include/location-hns +++ b/docker/nginx/conf.d/include/location-hns @@ -81,8 +81,8 @@ proxy_pass https://127.0.0.1/$skylink$path$is_args$args; # in case siad returns location header, we need to replace the skylink with the domain name header_filter_by_lua_block { - ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. os.getenv("PORTAL_DOMAIN") - ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. os.getenv("SERVER_DOMAIN") + ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain + ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain if ngx.header.location then -- match location redirect part after the skylink diff --git a/docker/nginx/conf.d/include/location-skylink b/docker/nginx/conf.d/include/location-skylink index db3a7b86..da4727c7 100644 --- a/docker/nginx/conf.d/include/location-skylink +++ b/docker/nginx/conf.d/include/location-skylink @@ -85,8 +85,8 @@ access_by_lua_block { } header_filter_by_lua_block { - ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. os.getenv("PORTAL_DOMAIN") - ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. os.getenv("SERVER_DOMAIN") + ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain + ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain -- the block below only makes sense if we are using nginx cache if not ngx.var.skyd_disk_cache_enabled then diff --git a/docker/nginx/conf.d/server/server.account b/docker/nginx/conf.d/server/server.account index debfe572..127ba4bf 100644 --- a/docker/nginx/conf.d/server/server.account +++ b/docker/nginx/conf.d/server/server.account @@ -35,9 +35,23 @@ location /api/register { proxy_pass http://accounts:3000; } +location /api/user/pubkey/register { + include /etc/nginx/conf.d/include/cors; + + rewrite /api/(.*) /$1 break; + proxy_pass http://accounts:3000; +} + location /api/login { include /etc/nginx/conf.d/include/cors; rewrite /api/(.*) /$1 break; proxy_pass http://accounts:3000; } + +location /api/logout { + include /etc/nginx/conf.d/include/cors; + + rewrite /api/(.*) /$1 break; + proxy_pass http://accounts:3000; +} diff --git a/docker/nginx/conf.d/server/server.api b/docker/nginx/conf.d/server/server.api index 7e2489fa..d55c2c98 100644 --- a/docker/nginx/conf.d/server/server.api +++ b/docker/nginx/conf.d/server/server.api @@ -46,15 +46,29 @@ location /docs { location /skynet/blocklist { include /etc/nginx/conf.d/include/cors; + add_header X-Proxy-Cache $upstream_cache_status; + proxy_cache skynet; proxy_cache_valid any 1m; # cache blocklist for 1 minute proxy_set_header User-Agent: Sia-Agent; proxy_pass http://sia:9980/skynet/blocklist; } +location /skynet/portal/blocklist { + include /etc/nginx/conf.d/include/cors; + + add_header X-Proxy-Cache $upstream_cache_status; + + proxy_cache skynet; + proxy_cache_valid 200 204 15m; # cache portal blocklist for 15 minutes + proxy_pass http://blocker:4000/blocklist; +} + location /skynet/portals { include /etc/nginx/conf.d/include/cors; + add_header X-Proxy-Cache $upstream_cache_status; + proxy_cache skynet; proxy_cache_valid any 1m; # cache portals for 1 minute proxy_set_header User-Agent: Sia-Agent; @@ -64,6 +78,8 @@ location /skynet/portals { location /skynet/stats { include /etc/nginx/conf.d/include/cors; + add_header X-Proxy-Cache $upstream_cache_status; + proxy_cache skynet; proxy_cache_valid any 1m; # cache stats for 1 minute proxy_set_header User-Agent: Sia-Agent; @@ -89,6 +105,8 @@ location /serverload { location /skynet/health { include /etc/nginx/conf.d/include/cors; + add_header X-Proxy-Cache $upstream_cache_status; + proxy_cache skynet; proxy_cache_key $request_uri; # use whole request uri (uri + args) as cache key proxy_cache_valid any 1m; # cache responses for 1 minute @@ -247,8 +265,7 @@ location /skynet/tus { proxy_set_header X-Forwarded-Proto $scheme; # rewrite proxy request to use correct host uri from env variable (required to return correct location header) - set_by_lua_block $server_domain { return os.getenv("SERVER_DOMAIN") } - proxy_redirect $scheme://$host $scheme://$server_domain; + proxy_redirect $scheme://$host $scheme://$skynet_server_domain; # proxy /skynet/tus requests to siad endpoint with all arguments proxy_pass http://sia:9980; @@ -275,8 +292,8 @@ location /skynet/tus { # extract skylink from base64 encoded upload metadata and assign to a proper header header_filter_by_lua_block { - ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. os.getenv("PORTAL_DOMAIN") - ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. os.getenv("SERVER_DOMAIN") + ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain + ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain if ngx.header["Upload-Metadata"] then local encodedSkylink = string.match(ngx.header["Upload-Metadata"], "Skylink ([^,?]+)") @@ -310,8 +327,8 @@ location /skynet/metadata { include /etc/nginx/conf.d/include/portal-access-check; header_filter_by_lua_block { - ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. os.getenv("PORTAL_DOMAIN") - ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. os.getenv("SERVER_DOMAIN") + ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain + ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain } proxy_set_header User-Agent: Sia-Agent; @@ -323,8 +340,8 @@ location /skynet/resolve { include /etc/nginx/conf.d/include/portal-access-check; header_filter_by_lua_block { - ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. os.getenv("PORTAL_DOMAIN") - ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. os.getenv("SERVER_DOMAIN") + ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain + ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain } proxy_set_header User-Agent: Sia-Agent; diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 64397630..7f43d192 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -95,8 +95,8 @@ http { # include skynet-portal-api and skynet-server-api header on every request header_filter_by_lua_block { - ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. os.getenv("PORTAL_DOMAIN") - ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. os.getenv("SERVER_DOMAIN") + ngx.header["Skynet-Portal-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_portal_domain + ngx.header["Skynet-Server-Api"] = ngx.var.scheme .. "://" .. ngx.var.skynet_server_domain } # ratelimit specified IPs diff --git a/packages/dashboard-v2/.storybook/main.js b/packages/dashboard-v2/.storybook/main.js index 09e2ce48..8a1198a4 100644 --- a/packages/dashboard-v2/.storybook/main.js +++ b/packages/dashboard-v2/.storybook/main.js @@ -3,6 +3,7 @@ module.exports = { addons: [ "@storybook/addon-links", "@storybook/addon-essentials", + "storybook-addon-gatsby", { name: "@storybook/addon-postcss", options: { diff --git a/packages/dashboard-v2/gatsby-config.js b/packages/dashboard-v2/gatsby-config.js index b82a34b1..017a4dfc 100644 --- a/packages/dashboard-v2/gatsby-config.js +++ b/packages/dashboard-v2/gatsby-config.js @@ -1,3 +1,5 @@ +const { createProxyMiddleware } = require("http-proxy-middleware"); + module.exports = { siteMetadata: { title: `Accounts Dashboard`, @@ -9,6 +11,7 @@ module.exports = { "gatsby-plugin-react-helmet", "gatsby-plugin-sharp", "gatsby-transformer-sharp", + "gatsby-plugin-styled-components", "gatsby-plugin-postcss", { resolve: "gatsby-source-filesystem", @@ -19,4 +22,14 @@ module.exports = { __key: "images", }, ], + developMiddleware: (app) => { + app.use( + "/api/", + createProxyMiddleware({ + target: "https://account.siasky.net", + secure: false, // Do not reject self-signed certificates. + changeOrigin: true, + }) + ); + }, }; diff --git a/packages/dashboard-v2/gatsby-ssr.js b/packages/dashboard-v2/gatsby-ssr.js new file mode 100644 index 00000000..a71e49c3 --- /dev/null +++ b/packages/dashboard-v2/gatsby-ssr.js @@ -0,0 +1,13 @@ +import * as React from "react"; +import "@fontsource/sora/300.css"; // light +import "@fontsource/sora/400.css"; // normal +import "@fontsource/sora/500.css"; // medium +import "@fontsource/sora/600.css"; // semibold +import "@fontsource/source-sans-pro/400.css"; // normal +import "@fontsource/source-sans-pro/600.css"; // semibold +import "./src/styles/global.css"; + +export function wrapPageElement({ element, props }) { + const Layout = element.type.Layout ?? React.Fragment; + return {element}; +} diff --git a/packages/dashboard-v2/package.json b/packages/dashboard-v2/package.json index c5bb6214..7b561154 100644 --- a/packages/dashboard-v2/package.json +++ b/packages/dashboard-v2/package.json @@ -21,13 +21,23 @@ "dependencies": { "@fontsource/sora": "^4.5.3", "@fontsource/source-sans-pro": "^4.5.3", + "classnames": "^2.3.1", + "copy-text-to-clipboard": "^3.0.1", + "dayjs": "^1.10.8", "gatsby": "^4.6.2", "gatsby-plugin-postcss": "^5.7.0", + "http-status-codes": "^2.2.0", + "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", "react-helmet": "^6.1.0", "react-use": "^17.3.2", + "skynet-js": "^3.0.2", + "swr": "^1.2.2", "tailwindcss": "^3.0.23" }, "devDependencies": { @@ -44,20 +54,24 @@ "autoprefixer": "^10.4.2", "babel-eslint": "^10.1.0", "babel-loader": "^8.2.3", + "babel-plugin-preval": "^5.1.0", "babel-plugin-styled-components": "^2.0.2", "eslint": "^8.9.0", "eslint-config-react-app": "^7.0.0", "eslint-plugin-storybook": "^0.5.6", "gatsby-plugin-alias-imports": "^1.0.5", "gatsby-plugin-image": "^2.6.0", + "gatsby-plugin-preval": "^1.0.0", "gatsby-plugin-provide-react": "^1.0.2", "gatsby-plugin-react-helmet": "^5.6.0", "gatsby-plugin-sharp": "^4.6.0", - "gatsby-plugin-styled-components": "^5.7.0", + "gatsby-plugin-styled-components": "^5.8.0", "gatsby-source-filesystem": "^4.6.0", "gatsby-transformer-sharp": "^4.6.0", + "http-proxy-middleware": "^1.3.1", "prettier": "2.5.1", "react-is": "^17.0.2", + "storybook-addon-gatsby": "^0.0.5", "styled-components": "^5.3.3" } } diff --git a/packages/dashboard-v2/src/components/Button/Button.js b/packages/dashboard-v2/src/components/Button/Button.js index 230a5a93..95ecbede 100644 --- a/packages/dashboard-v2/src/components/Button/Button.js +++ b/packages/dashboard-v2/src/components/Button/Button.js @@ -1,41 +1,21 @@ import PropTypes from "prop-types"; +import styled from "styled-components"; /** * Primary UI component for user interaction */ -export const Button = ({ primary, label, ...props }) => { - return ( - - ); -}; - +export const Button = styled.button.attrs(({ $primary }) => ({ + type: "button", + className: `px-6 py-3 rounded-full font-sans uppercase text-xs tracking-wide text-palette-600 transition-[filter] hover:brightness-90 + ${$primary ? "bg-primary" : "bg-white border-2 border-black"}`, +}))``; Button.propTypes = { /** * Is this the principal call to action on the page? */ - primary: PropTypes.bool, - /** - * What background color to use - */ - backgroundColor: PropTypes.string, - /** - * Button contents - */ - label: PropTypes.string.isRequired, - /** - * Optional click handler - */ - onClick: PropTypes.func, + $primary: PropTypes.bool, }; Button.defaultProps = { - primary: false, - onClick: undefined, + $primary: false, }; diff --git a/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js b/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js new file mode 100644 index 00000000..c5cdee36 --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentPlan/CurrentPlan.js @@ -0,0 +1,50 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + +import { useUser } from "../../contexts/user"; +import useSubscriptionPlans from "../../hooks/useSubscriptionPlans"; + +import LatestPayment from "./LatestPayment"; +import SuggestedPlan from "./SuggestedPlan"; + +dayjs.extend(relativeTime); + +const CurrentPlan = () => { + const { user, error: userError } = useUser(); + const { activePlan, plans, error: plansError } = useSubscriptionPlans(user); + + if (!user || !activePlan) { + return ( + // TODO: a nicer loading indicator +
Loading...
+ ); + } + + if (userError || plansError) { + return ( +
+

An error occurred while loading this data.

+

We'll retry automatically.

+
+ ); + } + + return ( +
+

{activePlan.name}

+
+ {activePlan.price === 0 &&

100GB without paying a dime! 🎉

} + {activePlan.price !== 0 && + (user.subscriptionCancelAtPeriodEnd ? ( +

Your subscription expires {dayjs(user.subscribedUntil).fromNow()}

+ ) : ( +

{dayjs(user.subscribedUntil).fromNow(true)} until the next payment

+ ))} + + +
+
+ ); +}; + +export default CurrentPlan; diff --git a/packages/dashboard-v2/src/components/CurrentPlan/LatestPayment.js b/packages/dashboard-v2/src/components/CurrentPlan/LatestPayment.js new file mode 100644 index 00000000..8ca2ab9e --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentPlan/LatestPayment.js @@ -0,0 +1,18 @@ +import dayjs from "dayjs"; + +// TODO: this is not an accurate information, we need this data from the backend +const LatestPayment = ({ user }) => ( +
+
+ Latest payment + + {dayjs(user.subscribedUntil).subtract(1, "month").format("MM/DD/YYYY")} + +
+
+ Success +
+
+); + +export default LatestPayment; diff --git a/packages/dashboard-v2/src/components/CurrentPlan/SuggestedPlan.js b/packages/dashboard-v2/src/components/CurrentPlan/SuggestedPlan.js new file mode 100644 index 00000000..21aa9b48 --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentPlan/SuggestedPlan.js @@ -0,0 +1,24 @@ +import { Link } from "gatsby"; +import { useMemo } from "react"; + +import { Button } from "../Button"; + +const SuggestedPlan = ({ plans, activePlan }) => { + const nextPlan = useMemo(() => plans.find(({ tier }) => tier > activePlan.tier), [plans, activePlan]); + + if (!nextPlan) { + return null; + } + + return ( +
+

Discover {nextPlan.name}

+

{nextPlan.description}

+ +
+ ); +}; + +export default SuggestedPlan; diff --git a/packages/dashboard-v2/src/components/CurrentPlan/index.js b/packages/dashboard-v2/src/components/CurrentPlan/index.js new file mode 100644 index 00000000..20390eab --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentPlan/index.js @@ -0,0 +1,3 @@ +import CurrentPlan from "./CurrentPlan"; + +export default CurrentPlan; diff --git a/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js b/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js new file mode 100644 index 00000000..b467e1ea --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentUsage/CurrentUsage.js @@ -0,0 +1,71 @@ +import * as React from "react"; +import fileSize from "pretty-bytes"; +import { Link } from "gatsby"; + +import { GraphBar } from "./GraphBar"; +import { UsageGraph } from "./UsageGraph"; + +// TODO: get real data +const useUsageData = () => ({ + files: { + used: 19_521, + limit: 20_000, + }, + storage: { + used: 23_000_000_000, + limit: 1_000_000_000_000, + }, +}); + +const size = (bytes) => { + const text = fileSize(bytes, { maximumFractionDigits: 1 }); + const [value, unit] = text.split(" "); + + return { + text, + value, + unit, + }; +}; + +export default function CurrentUsage() { + const { files, storage } = useUsageData(); + + const storageUsage = size(storage.used); + const storageLimit = size(storage.limit); + const filesUsedLabel = React.useMemo(() => ({ value: files.used, unit: "files" }), [files.used]); + + return ( + <> +

+ {storageUsage.text} of {storageLimit.text} +

+

+ {files.used} of {files.limit} files +

+
+
+ Storage + {storageLimit.text} +
+ + + + +
+ Files + + + UPGRADE + {" "} + {/* TODO: proper URL */} + {files.limit} + +
+
+ + ); +} diff --git a/packages/dashboard-v2/src/components/CurrentUsage/GraphBar.js b/packages/dashboard-v2/src/components/CurrentUsage/GraphBar.js new file mode 100644 index 00000000..96421f6e --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentUsage/GraphBar.js @@ -0,0 +1,35 @@ +import styled from "styled-components"; + +const Bar = styled.div.attrs({ + className: `relative flex justify-end h-4 bg-primary rounded-l rounded-r-lg`, +})` + min-width: 1rem; + width: ${({ $percentage }) => $percentage}%; +`; + +const BarTip = styled.span.attrs({ + className: "relative w-4 h-4 border-2 rounded-full bg-white border-primary", +})``; + +const BarLabel = styled.span.attrs({ + className: "bg-white rounded border-2 border-palette-200 px-3 whitespace-nowrap absolute shadow", +})` + right: max(0%, ${({ $percentage }) => 100 - $percentage}%); + top: -0.5rem; + transform: translateX(50%); +`; + +export const GraphBar = ({ value, limit, label }) => { + const percentage = typeof limit !== "number" || limit === 0 ? 0 : (value / limit) * 100; + + return ( +
+ + + + + {label.value} {label.unit} + +
+ ); +}; diff --git a/packages/dashboard-v2/src/components/CurrentUsage/UsageGraph.js b/packages/dashboard-v2/src/components/CurrentUsage/UsageGraph.js new file mode 100644 index 00000000..3f6f23c2 --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentUsage/UsageGraph.js @@ -0,0 +1,9 @@ +import styled from "styled-components"; + +export const UsageGraph = styled.div.attrs({ + className: "w-full my-3 grid grid-flow-row grid-rows-2", +})` + height: 146px; + background: url(/images/usage-graph-bg.svg) no-repeat; + background-size: cover; +`; diff --git a/packages/dashboard-v2/src/components/CurrentUsage/index.js b/packages/dashboard-v2/src/components/CurrentUsage/index.js new file mode 100644 index 00000000..802aa4e1 --- /dev/null +++ b/packages/dashboard-v2/src/components/CurrentUsage/index.js @@ -0,0 +1,3 @@ +import CurrentUsage from "./CurrentUsage"; + +export default CurrentUsage; diff --git a/packages/dashboard-v2/src/components/Icons/icons/CircledArrowUpIcon.js b/packages/dashboard-v2/src/components/Icons/icons/CircledArrowUpIcon.js new file mode 100644 index 00000000..f27742d4 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/CircledArrowUpIcon.js @@ -0,0 +1,18 @@ +import { withIconProps } from "../withIconProps"; + +export const CircledArrowUpIcon = withIconProps(({ size, ...props }) => ( + + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/CircledCheckmarkIcon.js b/packages/dashboard-v2/src/components/Icons/icons/CircledCheckmarkIcon.js new file mode 100644 index 00000000..5f0cfc31 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/CircledCheckmarkIcon.js @@ -0,0 +1,18 @@ +import { withIconProps } from "../withIconProps"; + +export const CircledCheckmarkIcon = withIconProps(({ size, ...props }) => ( + + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/CircledErrorIcon.js b/packages/dashboard-v2/src/components/Icons/icons/CircledErrorIcon.js new file mode 100644 index 00000000..388ae1b4 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/CircledErrorIcon.js @@ -0,0 +1,19 @@ +import { withIconProps } from "../withIconProps"; + +export const CircledErrorIcon = withIconProps(({ size, ...props }) => ( + + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/CircledProgressIcon.js b/packages/dashboard-v2/src/components/Icons/icons/CircledProgressIcon.js new file mode 100644 index 00000000..95c6fbcd --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/CircledProgressIcon.js @@ -0,0 +1,22 @@ +import { withIconProps } from "../withIconProps"; + +export const CircledProgressIcon = withIconProps(({ size, ...props }) => ( + + + + + + + + + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/FolderUploadIcon.js b/packages/dashboard-v2/src/components/Icons/icons/FolderUploadIcon.js new file mode 100644 index 00000000..b54e3e4f --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/FolderUploadIcon.js @@ -0,0 +1,24 @@ +import { withIconProps } from "../withIconProps"; + +export const FolderUploadIcon = withIconProps((props) => ( + + + + + + + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/PlusIcon.js b/packages/dashboard-v2/src/components/Icons/icons/PlusIcon.js new file mode 100644 index 00000000..48dc64f0 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/PlusIcon.js @@ -0,0 +1,14 @@ +import { withIconProps } from "../withIconProps"; + +export const PlusIcon = withIconProps(({ size, ...props }) => ( + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/icons/UploadIcon.js b/packages/dashboard-v2/src/components/Icons/icons/UploadIcon.js new file mode 100644 index 00000000..8f7d1ee6 --- /dev/null +++ b/packages/dashboard-v2/src/components/Icons/icons/UploadIcon.js @@ -0,0 +1,40 @@ +import { withIconProps } from "../withIconProps"; + +export const UploadIcon = withIconProps((props) => ( + + + + + + + + + + +)); diff --git a/packages/dashboard-v2/src/components/Icons/index.js b/packages/dashboard-v2/src/components/Icons/index.js index 28f87b15..41552e34 100644 --- a/packages/dashboard-v2/src/components/Icons/index.js +++ b/packages/dashboard-v2/src/components/Icons/index.js @@ -4,3 +4,8 @@ export * from "./icons/LockClosedIcon"; export * from "./icons/SkynetLogoIcon"; export * from "./icons/ArrowRightIcon"; export * from "./icons/InfoIcon"; +export * from "./icons/CircledCheckmarkIcon"; +export * from "./icons/CircledErrorIcon"; +export * from "./icons/CircledProgressIcon"; +export * from "./icons/CircledArrowUpIcon"; +export * from "./icons/PlusIcon"; diff --git a/packages/dashboard-v2/src/components/LatestActivity/ActivityTable.js b/packages/dashboard-v2/src/components/LatestActivity/ActivityTable.js new file mode 100644 index 00000000..647f9bf8 --- /dev/null +++ b/packages/dashboard-v2/src/components/LatestActivity/ActivityTable.js @@ -0,0 +1,40 @@ +import * as React from "react"; +import useSWR from "swr"; + +import { Table, TableBody, TableCell, TableRow } from "../Table"; + +import useFormattedActivityData from "./useFormattedActivityData"; + +export default function ActivityTable({ type }) { + const { data, error } = useSWR(`user/${type}?pageSize=3`); + const items = useFormattedActivityData(data?.items || []); + + if (!items.length) { + return ( +
+ {/* TODO: proper loading indicator / error message */} + {!data && !error &&

Loading...

} + {!data && error &&

An error occurred while loading this data.

} + {data &&

No files found.

} +
+ ); + } + + return ( + + + {items.map(({ id, name, type, size, date, skylink }) => ( + + {name} + {type} + + {size} + + {date} + {skylink} + + ))} + +
+ ); +} diff --git a/packages/dashboard-v2/src/components/LatestActivity/LatestActivity.js b/packages/dashboard-v2/src/components/LatestActivity/LatestActivity.js new file mode 100644 index 00000000..9c53554a --- /dev/null +++ b/packages/dashboard-v2/src/components/LatestActivity/LatestActivity.js @@ -0,0 +1,36 @@ +import * as React from "react"; +import { Link } from "gatsby"; + +import { Panel } from "../Panel"; +import { Tab, TabPanel, Tabs } from "../Tabs"; +import { ArrowRightIcon } from "../Icons"; + +import ActivityTable from "./ActivityTable"; + +const ViewAllLink = (props) => ( + + + + + View all + +); + +export default function LatestActivity() { + return ( + + + + + + + + + + + + + + + ); +} diff --git a/packages/dashboard-v2/src/components/LatestActivity/index.js b/packages/dashboard-v2/src/components/LatestActivity/index.js new file mode 100644 index 00000000..32a02f82 --- /dev/null +++ b/packages/dashboard-v2/src/components/LatestActivity/index.js @@ -0,0 +1,3 @@ +import LatestActivity from "./LatestActivity"; + +export default LatestActivity; diff --git a/packages/dashboard-v2/src/components/LatestActivity/useFormattedActivityData.js b/packages/dashboard-v2/src/components/LatestActivity/useFormattedActivityData.js new file mode 100644 index 00000000..cf55703c --- /dev/null +++ b/packages/dashboard-v2/src/components/LatestActivity/useFormattedActivityData.js @@ -0,0 +1,26 @@ +import { useMemo } from "react"; +import prettyBytes from "pretty-bytes"; +import dayjs from "dayjs"; + +const parseFileName = (fileName) => { + const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity; + + return [fileName.substr(0, lastDotIndex), fileName.substr(lastDotIndex)]; +}; + +const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => { + const [name, type] = parseFileName(rawFileName); + const date = dayjs(uploadedOn || downloadedOn).format("MM/DD/YYYY; HH:MM"); + + return { + ...rest, + date, + size: prettyBytes(size), + type, + name, + }; +}; + +const useFormattedActivityData = (items) => useMemo(() => items.map(formatItem), [items]); + +export default useFormattedActivityData; diff --git a/packages/dashboard-v2/src/components/NavBar/NavBar.js b/packages/dashboard-v2/src/components/NavBar/NavBar.js index 257b8a0d..2b53f2be 100644 --- a/packages/dashboard-v2/src/components/NavBar/NavBar.js +++ b/packages/dashboard-v2/src/components/NavBar/NavBar.js @@ -1,21 +1,78 @@ +import { Link } from "gatsby"; import styled from "styled-components"; +import { screen } from "../../lib/cssHelpers"; +import { DropdownMenu, DropdownMenuLink } from "../DropdownMenu"; +import { CogIcon, LockClosedIcon, SkynetLogoIcon } from "../Icons"; import { PageContainer } from "../PageContainer"; +import { NavBarLink, NavBarSection } from "."; + const NavBarContainer = styled.div.attrs({ className: `grid sticky top-0 bg-white`, })``; const NavBarBody = styled.nav.attrs({ - className: "grid h-[80px] font-sans font-light text-sm", + className: "grid font-sans font-light text-xs sm:text-sm", })` - grid-template-columns: auto max-content 1fr; + height: 100px; + grid-template-columns: 1fr 1fr; + grid-template-rows: 60px 40px; + grid-template-areas: + "logo dropdown" + "navigation navigation"; + + ${screen( + "sm", + ` + height: 80px; + grid-template-columns: auto max-content 1fr; + grid-template-areas: "logo navigation dropdown"; + grid-template-rows: auto; + ` + )} + + .navigation-area { + grid-area: navigation; + display: grid; + grid-template-columns: repeat(3, 1fr); + } + + .logo-area { + grid-area: logo; + justify-content: start; + } + + .dropdown-area { + grid-area: dropdown; + } `; -export const NavBar = (props) => ( +export const NavBar = () => ( - - + + + + + + + + Dashboard + + + Files + + + Payments + + + + + + + + + ); diff --git a/packages/dashboard-v2/src/components/NavBar/NavBar.stories.js b/packages/dashboard-v2/src/components/NavBar/NavBar.stories.js index e1022faa..0dc7c925 100644 --- a/packages/dashboard-v2/src/components/NavBar/NavBar.stories.js +++ b/packages/dashboard-v2/src/components/NavBar/NavBar.stories.js @@ -9,17 +9,7 @@ export default { }, }; -const Template = (props) => ( - - - - Dashboard - - Files - Payments - - -); +const Template = (props) => ; export const DashboardTopNavigation = Template.bind({}); DashboardTopNavigation.args = {}; diff --git a/packages/dashboard-v2/src/components/NavBar/NavBarLink.js b/packages/dashboard-v2/src/components/NavBar/NavBarLink.js index 772933c0..3c85dcda 100644 --- a/packages/dashboard-v2/src/components/NavBar/NavBarLink.js +++ b/packages/dashboard-v2/src/components/NavBar/NavBarLink.js @@ -3,7 +3,7 @@ import styled from "styled-components"; export const NavBarLink = styled.a.attrs(({ active }) => ({ className: ` - min-w-[168px] + sm:min-w-[133px] lg:min-w-[168px] flex h-full items-center justify-center border-x border-x-palette-100 border-b-2 text-palette-600 transition-colors hover:bg-palette-100/50 diff --git a/packages/dashboard-v2/src/components/PageContainer/PageContainer.js b/packages/dashboard-v2/src/components/PageContainer/PageContainer.js index 9c95b59c..2fa037a8 100644 --- a/packages/dashboard-v2/src/components/PageContainer/PageContainer.js +++ b/packages/dashboard-v2/src/components/PageContainer/PageContainer.js @@ -2,7 +2,7 @@ import PropTypes from "prop-types"; import styled from "styled-components"; export const PageContainer = styled.div.attrs({ - className: `mx-auto w-page md:w-page-md lg:w-page-lg xl:w-page-xl`, + className: `mx-auto w-page lg:w-page-lg xl:w-page-xl px-2 md:px-16 lg:px-0`, })``; PageContainer.propTypes = { diff --git a/packages/dashboard-v2/src/components/Panel/Panel.js b/packages/dashboard-v2/src/components/Panel/Panel.js index ea79b4d1..27551ecd 100644 --- a/packages/dashboard-v2/src/components/Panel/Panel.js +++ b/packages/dashboard-v2/src/components/Panel/Panel.js @@ -25,7 +25,7 @@ Panel.propTypes = { /** * Label of the panel */ - title: PropTypes.string, + title: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), }; Panel.defaultProps = { diff --git a/packages/dashboard-v2/src/components/Slider/Bullets.js b/packages/dashboard-v2/src/components/Slider/Bullets.js new file mode 100644 index 00000000..d7dff453 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/Bullets.js @@ -0,0 +1,29 @@ +import PropTypes from "prop-types"; + +export default function Bullets({ visibleSlides, activeIndex, allSlides, changeSlide }) { + if (allSlides <= visibleSlides) { + return null; + } + + return ( +
+ {Array(allSlides - visibleSlides + 1) + .fill(null) + .map((_, index) => ( +
+ ); +} + +Bullets.propTypes = { + allSlides: PropTypes.number.isRequired, + activeIndex: PropTypes.number.isRequired, + visibleSlides: PropTypes.number.isRequired, + changeSlide: PropTypes.func.isRequired, +}; diff --git a/packages/dashboard-v2/src/components/Slider/Slide.js b/packages/dashboard-v2/src/components/Slider/Slide.js new file mode 100644 index 00000000..4f700502 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/Slide.js @@ -0,0 +1,12 @@ +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"}`, +}))``; + +Slide.propTypes = { + isVisible: PropTypes.bool.isRequired, +}; + +export default Slide; diff --git a/packages/dashboard-v2/src/components/Slider/Slider.js b/packages/dashboard-v2/src/components/Slider/Slider.js new file mode 100644 index 00000000..ae311242 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/Slider.js @@ -0,0 +1,128 @@ +import * as React from "react"; +import PropTypes from "prop-types"; +import styled, { css } from "styled-components"; + +import useActiveBreakpoint from "./useActiveBreakpoint"; +import Bullets from "./Bullets"; +import Slide from "./Slide"; + +const Container = styled.div.attrs({ + className: "slider w-full", +})``; + +/** + * Styles applied to the movable element when the number of slide elements + * exceeds the number of visible slides for the current breakpoint + * */ +const scrollableStyles = css` + ${({ $allSlides, $visibleSlides, $activeIndex }) => ` + transform: translateX(calc(-1 * ${$activeIndex} * ((100% + 1rem) / ${$visibleSlides}))); + grid-template-columns: repeat(${$allSlides}, calc((100% - ${$visibleSlides - 1}rem) / ${$visibleSlides})); + `} +`; + +const Scroller = styled.div.attrs({ + className: "slider-scroller grid gap-4 transition-transform", +})` + ${({ $scrollable }) => ($scrollable ? scrollableStyles : "")} +`; + +const Slider = ({ slides, breakpoints }) => { + const { visibleSlides, scrollable } = useActiveBreakpoint(breakpoints); + const [activeIndex, setActiveIndex] = React.useState(0); + const changeSlide = React.useCallback( + (event, index) => { + event.preventDefault(); + event.stopPropagation(); + setActiveIndex(Math.min(index, slides.length - visibleSlides)); // Don't let it scroll too far + }, + [slides, visibleSlides, setActiveIndex] + ); + + React.useEffect(() => { + const maxIndex = slides.length - visibleSlides; + + // Make sure to not scroll too far when screen size changes. + if (activeIndex > maxIndex) { + setActiveIndex(maxIndex); + } + }, [slides.length, visibleSlides, activeIndex]); + + return ( + + + {slides.map((slide, index) => { + const isVisible = index >= activeIndex && index < activeIndex + visibleSlides; + + return ( +
+ changeSlide(event, index) : null} + > + {slide} + +
+ ); + })} +
+ {scrollable && ( + + )} +
+ ); +}; + +Slider.propTypes = { + slides: PropTypes.arrayOf(PropTypes.node.isRequired), + breakpoints: PropTypes.arrayOf( + PropTypes.shape({ + /** + * Breakpoint name as defined in Tailwind config. If not defined, config + * will be applied to all non-configured screen sizes. + */ + name: PropTypes.string, + /** + * Number of slides visible for a given breakpoint. + */ + visibleSlides: PropTypes.number.isRequired, + /** + * Whether or not the list should be scrollable horizontally at the given breakpoint. + * If set to false, all slides will be visible & rendered in a column. + */ + scrollable: PropTypes.bool.isRequired, + }) + ), +}; + +Slider.defaultProps = { + breakpoints: [ + { + name: "xl", + scrollable: true, + visibleSlides: 3, + }, + { + name: "md", + scrollable: true, + visibleSlides: 2, + }, + { + // For the smallest screens, we won't scroll but instead stack the slides vertically. + scrollable: false, + visibleSlides: 1, + }, + ], +}; + +export default Slider; diff --git a/packages/dashboard-v2/src/components/Slider/index.js b/packages/dashboard-v2/src/components/Slider/index.js new file mode 100644 index 00000000..a5890919 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/index.js @@ -0,0 +1 @@ +export * from "./Slider"; diff --git a/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js b/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js new file mode 100644 index 00000000..4b891ba7 --- /dev/null +++ b/packages/dashboard-v2/src/components/Slider/useActiveBreakpoint.js @@ -0,0 +1,38 @@ +import { useEffect, useMemo, useCallback, useState } from "react"; +import { useWindowSize } from "react-use"; + +import theme from "../../lib/theme"; + +const { screens } = theme; + +export default function useActiveBreakpoint(breakpoints) { + const { width: windowWidth } = useWindowSize(); + const monitoredBreakpoints = useMemo( + () => + breakpoints + .slice() + // Map breakpoint names to their min-width configured in Tailwind + .map(({ name, ...config }) => { + // If breakpoint name is not configured, + // we'll apply this config to all unmatched breakpoints. + const minWidth = screens[name] ? parseInt(screens[name], 10) : -Infinity; + + return { minWidth, ...config }; + }) + // Since our breakpoints are setup with min-width rule, we need to sort them from largest to smallest + .sort(({ minWidth: widthA }, { minWidth: widthB }) => widthB - widthA), + [breakpoints] + ); + const findActiveBreakpoint = useCallback( + () => monitoredBreakpoints.find((breakpoint) => windowWidth >= breakpoint.minWidth), + [monitoredBreakpoints, windowWidth] + ); + + const [activeBreakpoint, setActiveBreakpoint] = useState(findActiveBreakpoint()); + + useEffect(() => { + setActiveBreakpoint(findActiveBreakpoint()); + }, [findActiveBreakpoint]); + + return activeBreakpoint; +} diff --git a/packages/dashboard-v2/src/components/Table/TableCell.js b/packages/dashboard-v2/src/components/Table/TableCell.js index 98f2bd3d..12a45a72 100644 --- a/packages/dashboard-v2/src/components/Table/TableCell.js +++ b/packages/dashboard-v2/src/components/Table/TableCell.js @@ -4,7 +4,7 @@ import styled from "styled-components"; * Accepts all HMTL attributes a `` element does. */ export const TableCell = styled.td.attrs({ - className: `px-6 py-4 h-tableRow truncate + className: `first:pl-6 last:pr-6 px-2 py-4 h-tableRow truncate text-palette-600 even:text-palette-400 first:rounded-l-sm last:rounded-r-sm`, })` diff --git a/packages/dashboard-v2/src/components/Tabs/ActiveTabIndicator.js b/packages/dashboard-v2/src/components/Tabs/ActiveTabIndicator.js index f6f89266..e39a5f08 100644 --- a/packages/dashboard-v2/src/components/Tabs/ActiveTabIndicator.js +++ b/packages/dashboard-v2/src/components/Tabs/ActiveTabIndicator.js @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import PropTypes from "prop-types"; import styled from "styled-components"; +import { useWindowSize } from "react-use"; const Wrapper = styled.div.attrs({ className: "absolute left-0 bottom-0 w-full h-0.5 bg-palette-200", @@ -15,6 +16,7 @@ const Indicator = styled.div.attrs({ export const ActiveTabIndicator = ({ tabRef }) => { const [position, setPosition] = useState(0); const [width, setWidth] = useState(0); + const { width: windowWidth } = useWindowSize(); useEffect(() => { if (!tabRef?.current) { @@ -24,7 +26,7 @@ export const ActiveTabIndicator = ({ tabRef }) => { const { offsetLeft, offsetWidth } = tabRef.current; setPosition(offsetLeft); setWidth(offsetWidth); - }, [tabRef]); + }, [tabRef, windowWidth]); return ( @@ -33,6 +35,9 @@ export const ActiveTabIndicator = ({ tabRef }) => { ); }; -ActiveTabIndicator.propTypes = { - tabRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) })]), -}; +// Needed, because we're using an Element constant here which Gatsby doesn't recognize during build time. +if (typeof window !== "undefined") { + ActiveTabIndicator.propTypes = { + tabRef: PropTypes.oneOfType([PropTypes.func, PropTypes.shape({ current: PropTypes.instanceOf(Element) })]), + }; +} diff --git a/packages/dashboard-v2/src/components/Tabs/TabPanel.js b/packages/dashboard-v2/src/components/Tabs/TabPanel.js index a22becc6..f9907074 100644 --- a/packages/dashboard-v2/src/components/Tabs/TabPanel.js +++ b/packages/dashboard-v2/src/components/Tabs/TabPanel.js @@ -1,15 +1,33 @@ import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; /** * Besides documented props, it accepts all HMTL attributes a `
` element does. */ export const TabPanel = ({ children, active, tabId, ...props }) => { - if (!active) { + const [wasActivated, setWasActivated] = useState(false); + + useEffect(() => { + if (active) { + setWasActivated(true); + } + }, [active]); + + // If the TabPanel was never activated, let's not render its children at all. + // We'll only render them after the first activation and then won't unmount them + // unless the entire TabPanel is unmounted, too. + if (!active && !wasActivated) { return null; } return ( -
+
{children}
); diff --git a/packages/dashboard-v2/src/components/Tabs/Tabs.js b/packages/dashboard-v2/src/components/Tabs/Tabs.js index 14356466..4bf20ccf 100644 --- a/packages/dashboard-v2/src/components/Tabs/Tabs.js +++ b/packages/dashboard-v2/src/components/Tabs/Tabs.js @@ -6,11 +6,11 @@ import { ActiveTabIndicator } from "./ActiveTabIndicator"; import { usePrefixedTabIds, useTabsChildren } from "./hooks"; const Container = styled.div.attrs({ - className: "tabs-container", + className: "tabs-container flex flex-col h-full", })``; const Header = styled.div.attrs({ - className: "relative flex justify-start overflow-hidden", + className: "relative flex justify-start overflow-hidden grow-0 shrink-0", })``; const TabList = styled.div.attrs(({ variant }) => ({ @@ -26,7 +26,7 @@ const Divider = styled.div.attrs({ right: calc(-100vw - 2px); `; -const Body = styled.div``; +const Body = styled.div.attrs({ className: "grow min-h-0" })``; /** * Besides documented props, it accepts all HMTL attributes a `
` element does. diff --git a/packages/dashboard-v2/src/components/Uploader/ProgressBar.js b/packages/dashboard-v2/src/components/Uploader/ProgressBar.js new file mode 100644 index 00000000..362c60a4 --- /dev/null +++ b/packages/dashboard-v2/src/components/Uploader/ProgressBar.js @@ -0,0 +1,71 @@ +import cn from "classnames"; +import PropTypes from "prop-types"; +import styled, { css, keyframes } from "styled-components"; + +const moveAnimation = keyframes` + 0% { + background-position: 0 0; + } + 100% { + background-position: 15px 0; + } +`; +const Container = styled.div.attrs(({ $status }) => ({ + className: cn("flex relative rounded-sm h-1", { "bg-palette-200": $status === "uploading" }), +}))``; + +const Indicator = styled.div.attrs(({ $status }) => ({ + className: cn( + ` + rounded-sm bg-[length:15px_10px] + `, + { + "bg-primary": $status === "uploading" || $status === "complete", + "text-primary": $status !== "error", + "text-error": $status === "error", + "bg-dashed": $status === "error" || $status === "enqueued" || $status === "processing", + } + ), +}))` + width: ${({ $status, $percentage }) => ($status === "uploading" ? $percentage : 100)}%; + &.bg-dashed { + opacity: 0.4; + background-image: linear-gradient( + -60deg, + transparent, + transparent 30%, + currentColor 30%, + currentColor 70%, + transparent 70%, + transparent + ); + animation: ${css` + ${moveAnimation} 1s linear infinite + `}; + } +`; + +/** + * Primary UI component for indicating progress of a given task + */ +export const ProgressBar = ({ status, percentage, ...props }) => ( + + + +); + +ProgressBar.propTypes = { + /** + * Status of the task + */ + status: PropTypes.oneOf(["complete", "enqueued", "error", "uploading", "processing"]), + /** + * Progress of the task (in case status is "uploading") + */ + percentage: PropTypes.number, +}; + +ProgressBar.defaultProps = { + status: "enqueued", + percentage: 0, +}; diff --git a/packages/dashboard-v2/src/components/Uploader/ProgressBar.stories.js b/packages/dashboard-v2/src/components/Uploader/ProgressBar.stories.js new file mode 100644 index 00000000..da4b6c19 --- /dev/null +++ b/packages/dashboard-v2/src/components/Uploader/ProgressBar.stories.js @@ -0,0 +1,37 @@ +import React from "react"; +import { ProgressBar } from "./ProgressBar"; + +// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export +export default { + title: "SkynetLibrary/ProgressBar", + component: ProgressBar, + // More on argTypes: https://storybook.js.org/docs/react/api/argtypes +}; + +// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args +const Template = (args) => ; + +export const Uploading = Template.bind({}); +// More on args: https://storybook.js.org/docs/react/writing-stories/args +Uploading.args = { + status: "uploading", + percentage: 65, +}; + +export const Complete = Template.bind({}); +// More on args: https://storybook.js.org/docs/react/writing-stories/args +Complete.args = { + status: "complete", +}; + +export const Enqueued = Template.bind({}); +// More on args: https://storybook.js.org/docs/react/writing-stories/args +Enqueued.args = { + status: "enqueued", +}; + +export const Error = Template.bind({}); +// More on args: https://storybook.js.org/docs/react/writing-stories/args +Error.args = { + status: "error", +}; diff --git a/packages/dashboard-v2/src/components/Uploader/Uploader.js b/packages/dashboard-v2/src/components/Uploader/Uploader.js new file mode 100644 index 00000000..269d9fbe --- /dev/null +++ b/packages/dashboard-v2/src/components/Uploader/Uploader.js @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import cn from "classnames"; +import path from "path-browserify"; +import { useDropzone } from "react-dropzone"; +import { nanoid } from "nanoid"; + +import { Button } from "../Button"; +import { UploadIcon } from "../Icons/icons/UploadIcon"; +import { FolderUploadIcon } from "../Icons/icons/FolderUploadIcon"; + +import UploaderItem from "./UploaderItem"; +import { PlusIcon } from "../Icons"; + +const MAX_PARALLEL_UPLOADS = 1; + +const getFilePath = (file) => file.webkitRelativePath || file.path || file.name; + +const getRootDirectory = (file) => { + const filePath = getFilePath(file); + const { root, dir } = path.parse(filePath); + + return path.normalize(dir).slice(root.length).split(path.sep)[0]; +}; + +const Uploader = ({ mode }) => { + const [uploads, setUploads] = useState([]); + + const onUploadStateChange = useCallback((id, state) => { + setUploads((uploads) => { + const index = uploads.findIndex((upload) => upload.id === id); + + return [...uploads.slice(0, index), { ...uploads[index], ...state }, ...uploads.slice(index + 1)]; + }); + }, []); + + const handleDrop = async (files) => { + if (mode === "directory" && files.length) { + const name = getRootDirectory(files[0]); // get the file path from the first file + const size = files.reduce((acc, file) => acc + file.size, 0); + + files = [{ name, size, files }]; + } + + setUploads((uploads) => [ + ...files.map((file) => ({ id: nanoid(), file, progress: 0, mode, status: "enqueued" })), + ...uploads, + ]); + }; + + useEffect(() => { + const enqueued = uploads.filter(({ status }) => status === "enqueued"); + const uploading = uploads.filter(({ status }) => ["uploading", "processing", "retrying"].includes(status)); + const queue = enqueued.slice(0, MAX_PARALLEL_UPLOADS - uploading.length).map(({ id }) => id); + + if (queue.length && uploading.length < MAX_PARALLEL_UPLOADS) { + setUploads((uploads) => { + return uploads.map((upload) => { + if (queue.includes(upload.id)) return { ...upload, status: "uploading" }; + return upload; + }); + }); + } + }, [uploads]); + + const { getRootProps, getInputProps, isDragActive, inputRef } = useDropzone({ + onDrop: handleDrop, + useFsAccessApi: false, + }); + const inputElement = inputRef.current; + + useEffect(() => { + if (!inputElement) return; + if (mode === "directory") inputElement.setAttribute("webkitdirectory", "true"); + if (mode === "file") inputElement.removeAttribute("webkitdirectory"); + }, [inputElement, mode]); + + return ( +
+
+ + {uploads.length === 0 ? ( +
+ {mode === "file" ? ( + <> + +

Add, or drop your files here to pin to Skynet

+ + ) : ( + <> + +

Drop any folder with an index.html file to deploy to Skynet

+ + )} + +
+ ) : ( +
+ + Add, or drop your files here +
+ )} +
+ + {uploads.length > 0 && ( +
+ {uploads.map((upload) => ( + + ))} +
+ )} +
+ ); +}; + +Uploader.propTypes = { + mode: PropTypes.oneOf(["file", "directory"]), +}; + +Uploader.defaultProps = { + mode: "file", +}; + +export default Uploader; diff --git a/packages/dashboard-v2/src/components/Uploader/UploaderItem.js b/packages/dashboard-v2/src/components/Uploader/UploaderItem.js new file mode 100644 index 00000000..4f47809c --- /dev/null +++ b/packages/dashboard-v2/src/components/Uploader/UploaderItem.js @@ -0,0 +1,133 @@ +import * as React from "react"; +import cn from "classnames"; +import bytes from "pretty-bytes"; +import { StatusCodes } from "http-status-codes"; +import copy from "copy-text-to-clipboard"; +import path from "path-browserify"; +import { useTimeoutFn } from "react-use"; +import { SkynetClient } from "skynet-js"; +import { ProgressBar } from "./ProgressBar"; +import UploaderItemIcon from "./UploaderItemIcon"; +import buildUploadErrorMessage from "./buildUploadErrorMessage"; + +const skynetClient = new SkynetClient("https://siasky.net"); //TODO: proper API url + +const getFilePath = (file) => file.webkitRelativePath || file.path || file.name; + +const getRelativeFilePath = (file) => { + const filePath = getFilePath(file); + const { root, dir, base } = path.parse(filePath); + const relative = path.normalize(dir).slice(root.length).split(path.sep).slice(1); + + return path.join(...relative, base); +}; + +export default function UploaderItem({ onUploadStateChange, upload }) { + const [copied, setCopied] = React.useState(false); + const [, , reset] = useTimeoutFn(() => setCopied(false), 3000); + const [retryTimeout, setRetryTimeout] = React.useState(3000); // retry delay after "429: TOO_MANY_REQUESTS" + + const handleCopy = (url) => { + copy(url); + setCopied(true); + reset(); + }; + + React.useEffect(() => { + if (upload.status === "uploading" && !upload.startedTime) { + onUploadStateChange(upload.id, { startedTime: Date.now() }); + + (async () => { + const onUploadProgress = (progress) => { + const status = progress === 1 ? "processing" : "uploading"; + + onUploadStateChange(upload.id, { status, progress }); + }; + + try { + let response; + + if (upload.mode === "directory") { + const files = upload.file.files; + const directory = files.reduce((acc, file) => ({ ...acc, [getRelativeFilePath(file)]: file }), {}); + const name = encodeURIComponent(upload.file.name); + + response = await skynetClient.uploadDirectory(directory, name, { onUploadProgress }); + } else { + response = await skynetClient.uploadFile(upload.file, { onUploadProgress }); + } + + const url = await skynetClient.getSkylinkUrl(response.skylink, { subdomain: upload.mode === "directory" }); + + onUploadStateChange(upload.id, { status: "complete", url }); + } catch (error) { + if (error?.response?.status === StatusCodes.TOO_MANY_REQUESTS) { + onUploadStateChange(upload.id, { status: "retrying", progress: 0 }); + + setTimeout(() => { + onUploadStateChange(upload.id, { status: "enqueued", startedTime: null }); + setRetryTimeout((timeout) => timeout * 2); // increase timeout on next retry + }, retryTimeout); + } else { + onUploadStateChange(upload.id, { status: "error", error: buildUploadErrorMessage(error) }); + } + } + })(); + } + }, [onUploadStateChange, upload, retryTimeout]); + + return ( +
+
+
+ +
+
+
+
{upload.file.name}
+
+
+
+ {upload.status === "uploading" && ( + + Uploading {bytes(upload.file.size * upload.progress)} of {bytes(upload.file.size)} + + )} + {upload.status === "enqueued" && Upload in queue, please wait} + {upload.status === "processing" && Processing...} + {upload.status === "complete" && ( + + {upload.url} + + )} + {upload.status === "error" && upload.error && {upload.error}} + {upload.status === "retrying" && ( + Too many parallel requests, retrying in {retryTimeout / 1000} seconds + )} +
+
+
+
+ {upload.status === "uploading" && ( + {Math.floor(upload.progress * 100)}% + )} + {upload.status === "processing" && Wait} + {upload.status === "complete" && ( + + )} +
+
+ + +
+ ); +} diff --git a/packages/dashboard-v2/src/components/Uploader/UploaderItemIcon.js b/packages/dashboard-v2/src/components/Uploader/UploaderItemIcon.js new file mode 100644 index 00000000..c0d6a766 --- /dev/null +++ b/packages/dashboard-v2/src/components/Uploader/UploaderItemIcon.js @@ -0,0 +1,20 @@ +import cn from "classnames"; + +import { CircledCheckmarkIcon, CircledErrorIcon, CircledProgressIcon, CircledArrowUpIcon } from "../Icons"; + +export default function UploaderItemIcon({ status }) { + switch (status) { + case "enqueued": + case "retrying": + case "uploading": + return ; + case "processing": + return ; + case "complete": + return ; + case "error": + return ; + default: + return null; + } +} diff --git a/packages/dashboard-v2/src/components/Uploader/buildUploadErrorMessage.js b/packages/dashboard-v2/src/components/Uploader/buildUploadErrorMessage.js new file mode 100644 index 00000000..c41cd717 --- /dev/null +++ b/packages/dashboard-v2/src/components/Uploader/buildUploadErrorMessage.js @@ -0,0 +1,37 @@ +import { getReasonPhrase } from "http-status-codes"; +import bytes from "pretty-bytes"; + +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 + if (error.response) { + if (error.response.data.message) { + return `Upload failed with error: ${error.response.data.message}`; + } + + const statusCode = error.response.status; + const statusText = getReasonPhrase(error.response.status); + + return `Upload failed, our server received your request but failed with status code: ${statusCode} ${statusText}`; + } + + // The request was made but no response was received. The best we can do is detect whether browser is online. + // This will be triggered mostly if the server is offline or misconfigured and doesn't respond to valid request. + if (error.request) { + if (!navigator.onLine) { + return "You are offline, please connect to the internet and try again"; + } + + // TODO: We should add a note "our team has been notified" and have some kind of notification with this error. + return "Server failed to respond to your request, please try again later."; + } + + // Match the error message to a message returned by TUS when upload exceeds max file size + 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))}`; + } + + // TODO: We should add a note "our team has been notified" and have some kind of notification with this error. + return `Critical error, please refresh the application and try again. ${error.message}`; +} diff --git a/packages/dashboard-v2/src/contexts/user/UserContext.js b/packages/dashboard-v2/src/contexts/user/UserContext.js new file mode 100644 index 00000000..e97723a3 --- /dev/null +++ b/packages/dashboard-v2/src/contexts/user/UserContext.js @@ -0,0 +1,6 @@ +import { createContext } from "react"; + +export const UserContext = createContext({ + user: null, + error: null, +}); diff --git a/packages/dashboard-v2/src/contexts/user/UserProvider.js b/packages/dashboard-v2/src/contexts/user/UserProvider.js new file mode 100644 index 00000000..4d1efac5 --- /dev/null +++ b/packages/dashboard-v2/src/contexts/user/UserProvider.js @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; + +import { UserContext } from "./UserContext"; + +export const UserProvider = ({ children }) => { + const { data: user, error, mutate } = useSWR("user"); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (user || error) { + setLoading(false); + } + }, [user, error]); + + return {children}; +}; diff --git a/packages/dashboard-v2/src/contexts/user/index.js b/packages/dashboard-v2/src/contexts/user/index.js new file mode 100644 index 00000000..311416e7 --- /dev/null +++ b/packages/dashboard-v2/src/contexts/user/index.js @@ -0,0 +1,2 @@ +export * from "./UserProvider"; +export * from "./useUser"; diff --git a/packages/dashboard-v2/src/contexts/user/useUser.js b/packages/dashboard-v2/src/contexts/user/useUser.js new file mode 100644 index 00000000..2b077961 --- /dev/null +++ b/packages/dashboard-v2/src/contexts/user/useUser.js @@ -0,0 +1,5 @@ +import { useContext } from "react"; + +import { UserContext } from "./UserContext"; + +export const useUser = () => useContext(UserContext); diff --git a/packages/dashboard-v2/src/hooks/useSubscriptionPlans.js b/packages/dashboard-v2/src/hooks/useSubscriptionPlans.js new file mode 100644 index 00000000..26658df8 --- /dev/null +++ b/packages/dashboard-v2/src/hooks/useSubscriptionPlans.js @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; +import freeTier from "../lib/tiers"; + +export default function useSubscriptionPlans(user) { + const { data: paidPlans, error, mutate } = useSWR("stripe/prices"); + const [plans, setPlans] = useState([freeTier]); + const [activePlan, setActivePlan] = useState(freeTier); + + useEffect(() => { + if (paidPlans) { + setPlans((plans) => [...plans, ...paidPlans].sort((planA, planB) => planA.tier - planB.tier)); + } + }, [paidPlans]); + + useEffect(() => { + if (user) { + setActivePlan(plans.find((plan) => plan.tier === user.tier)); + } + }, [plans, user]); + + return { + error, + mutate, + plans, + activePlan, + }; +} diff --git a/packages/dashboard-v2/src/layouts/DashboardLayout.js b/packages/dashboard-v2/src/layouts/DashboardLayout.js index 61d57870..07f4eabf 100644 --- a/packages/dashboard-v2/src/layouts/DashboardLayout.js +++ b/packages/dashboard-v2/src/layouts/DashboardLayout.js @@ -1,55 +1,52 @@ import * as React from "react"; -import { Link } from "gatsby"; - import styled from "styled-components"; -import { DropdownMenu, DropdownMenuLink } from "../components/DropdownMenu"; -import { PageContainer } from "../components/PageContainer"; -import { CogIcon, SkynetLogoIcon, LockClosedIcon } from "../components/Icons"; -import { NavBar, NavBarLink, NavBarSection } from "../components/Navbar"; -import { Footer } from "../components/Footer"; +import { SWRConfig } from "swr"; -const Layout = styled.div.attrs({ - className: "h-screen overflow-hidden", +import { authenticatedOnly } from "../lib/swrConfig"; + +import { PageContainer } from "../components/PageContainer"; +import { NavBar } from "../components/Navbar"; +import { Footer } from "../components/Footer"; +import { UserProvider, useUser } from "../contexts/user"; + +const Wrapper = styled.div.attrs({ + className: "min-h-screen overflow-hidden", })` background-image: url(/images/dashboard-bg.svg); - background-position: -300px -280px; - - .navbar { - grid-template-columns: auto max-content 1fr; - } + background-position: center -280px; + background-repeat: no-repeat; `; +const Layout = ({ children }) => { + const { user } = useUser(); + + // Prevent from flashing the dashboard screen to unauthenticated users. + return ( + + {!user && ( +
+

Loading...

{/* TODO: Do something nicer here */} +
+ )} + {user && <>{children}} +
+ ); +}; + const DashboardLayout = ({ children }) => { return ( <> - - - - - - - - Dashboard - - - Files - - - Payments - - - - - - - - - - -
{children}
-
-