├── public
├── favicon.png
├── logo@3x.png
├── icon-monsters.png
├── default-avatar.png
├── fonts
│ ├── Gilroy-Light.otf
│ ├── Gilroy-Medium.otf
│ ├── Gilroy-ExtraBold.otf
│ └── Gilroy-SemiBold.otf
├── icons-close.svg
├── icons-small-menu.svg
├── ellipsis.svg
├── close.svg
├── icon-light-chevron-down.svg
├── loading.svg
├── logo.svg
├── arrow.svg
├── down-arrow.svg
├── x.svg
├── launch.svg
├── vercel.svg
└── placeholder-asset.svg
├── .dockerignore
├── .prettierrc.js
├── next-env.d.ts
├── components
├── SalesHistoryTable
│ ├── SalesHistoryTable.styled.ts
│ └── index.tsx
├── TableHeaderRow
│ ├── TableHeader.styled.ts
│ └── index.tsx
├── TableRow
│ ├── TableRow.styled.ts
│ └── index.tsx
├── Modal
│ ├── index.tsx
│ ├── ClaimBalanceModal.tsx
│ ├── Modal.styled.ts
│ ├── CancelSaleModal.tsx
│ └── CreateSaleModal.tsx
├── LoadingPage
│ ├── LoadingPage.styled.ts
│ └── index.tsx
├── TableContentWraper
│ ├── TableContentWrapper.styled.ts
│ └── index.tsx
├── Provider
│ ├── index.ts
│ ├── ModalProvider.tsx
│ └── AuthProvider.tsx
├── TableHeaderCell
│ ├── index.tsx
│ └── TableHeaderCell.styled.ts
├── TableDataCell
│ ├── index.tsx
│ └── TableDataCell.styled.ts
├── Spinner
│ ├── index.tsx
│ └── Spinner.styled.ts
├── SalesHistoryTableCell
│ ├── SalesHistoryTabelCell.styled.ts
│ └── index.tsx
├── Grid
│ ├── Grid.styled.ts
│ └── index.tsx
├── PriceInput
│ ├── PriceInput.styled.ts
│ └── index.tsx
├── Button
│ ├── index.tsx
│ └── Button.styled.ts
├── Banner
│ ├── Banner.styled.ts
│ └── index.tsx
├── PageLayout
│ ├── PageLayout.styled.ts
│ └── index.tsx
├── Tooltip
│ ├── index.tsx
│ └── Tooltip.styled.ts
├── PaginationButton
│ ├── index.tsx
│ └── PaginationButton.styled.ts
├── AssetFormTitle
│ ├── AssetFormTitle.styled.ts
│ └── index.tsx
├── Error
│ ├── index.tsx
│ └── Error.styled.ts
├── AssetFormBuy
│ ├── AssetFormBuy.styled.ts
│ └── index.tsx
├── Footer
│ ├── index.tsx
│ └── Footer.styled.ts
├── AssetFormSellPopupMenu
│ ├── AssetFormSellPopupMenu.styled.ts
│ └── index.tsx
├── DetailsLayout
│ ├── DetailsLayout.styled.ts
│ └── index.tsx
├── GridCard
│ ├── GridCard.styled.ts
│ └── index.tsx
├── AssetFormSell
│ └── index.tsx
└── NavBar
│ ├── NavBar.styled.ts
│ └── index.tsx
├── kustomization.yaml
├── types
└── index.d.ts
├── styles
├── customprogress.css
├── FadeInImageContainer.styled.ts
├── Title.styled.ts
├── MaxWidth.styled.ts
├── globals.css
├── Breakpoints.ts
└── reset.css
├── babel.config.json
├── .env.template
├── .gitlab-ci.yml
├── next.config.js
├── letsencrypt-ssl-cert-issuer.yaml
├── deployment.yaml
├── certificate-storage.yaml
├── .github
└── workflows
│ ├── merge-request.yml
│ └── google.yml
├── .gitignore
├── utils
├── constants.ts
├── withCache.ts
├── gtag.ts
├── browser-fetch.ts
└── index.ts
├── tsconfig.json
├── ingress-tls.yaml
├── Dockerfile
├── pages
├── api
│ ├── profile.ts
│ └── upload.ts
├── _app.tsx
├── _document.js
├── index.tsx
├── my-nfts
│ └── [chainAccount].tsx
├── my-templates
│ └── [templateId].tsx
└── [templateId].tsx
├── services
├── cache.ts
├── offers.ts
├── proton-rpc.ts
├── assets.ts
├── sales.ts
├── templates.ts
└── proton.ts
├── package.json
├── hooks
└── index.ts
├── .eslintrc.json
└── README.md
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/public/logo@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/logo@3x.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | npm-debug.log
3 | Dockerfile
4 | .dockerignore
5 | .git
6 | .gitignore
--------------------------------------------------------------------------------
/public/icon-monsters.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/icon-monsters.png
--------------------------------------------------------------------------------
/public/default-avatar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/default-avatar.png
--------------------------------------------------------------------------------
/public/fonts/Gilroy-Light.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/fonts/Gilroy-Light.otf
--------------------------------------------------------------------------------
/public/fonts/Gilroy-Medium.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/fonts/Gilroy-Medium.otf
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | singleQuote: true,
3 | jsxBracketSameLine: true,
4 | semi: true,
5 | };
6 |
--------------------------------------------------------------------------------
/public/fonts/Gilroy-ExtraBold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/fonts/Gilroy-ExtraBold.otf
--------------------------------------------------------------------------------
/public/fonts/Gilroy-SemiBold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/XPRNetwork/nft-demo/HEAD/public/fonts/Gilroy-SemiBold.otf
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
--------------------------------------------------------------------------------
/components/SalesHistoryTable/SalesHistoryTable.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const StyledTable = styled.table`
4 | width: 100%;
5 | `;
6 |
--------------------------------------------------------------------------------
/kustomization.yaml:
--------------------------------------------------------------------------------
1 | commonLabels:
2 | app: nft-demo
3 | resources:
4 | - deployment.yaml
5 | - certificate-storage.yaml
6 | - ingress-tls.yaml
7 | - letsencrypt-ssl-cert-issuer.yaml
--------------------------------------------------------------------------------
/components/TableHeaderRow/TableHeader.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const StyledTableHeaderRow = styled.tr`
4 | background-color: #f6f7fe;
5 | `;
6 |
--------------------------------------------------------------------------------
/types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module '*.svg' {
2 | import React = require('react');
3 | export const ReactComponent: React.FC>;
4 | const src: string;
5 | export default src;
6 | }
7 |
--------------------------------------------------------------------------------
/components/TableRow/TableRow.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const StyledTableRow = styled.tr`
4 | &:nth-child(even) {
5 | background-color: #f6f7fe;
6 | }
7 | height: 64px;
8 | `;
9 |
--------------------------------------------------------------------------------
/components/Modal/index.tsx:
--------------------------------------------------------------------------------
1 | export { ClaimBalanceModal } from './ClaimBalanceModal';
2 | export { CancelSaleModal, CancelMultipleSalesModal } from './CancelSaleModal';
3 | export { CreateSaleModal, CreateMultipleSalesModal } from './CreateSaleModal';
4 |
--------------------------------------------------------------------------------
/public/icons-close.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icons-small-menu.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/ellipsis.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/styles/customprogress.css:
--------------------------------------------------------------------------------
1 | #nprogress {
2 | pointer-events: none;
3 | }
4 |
5 | #nprogress .bar {
6 | background: #8a9ef5;
7 |
8 | position: fixed;
9 | z-index: 1031;
10 | top: 0;
11 | left: 0;
12 |
13 | width: 100%;
14 | height: 4px;
15 | }
--------------------------------------------------------------------------------
/babel.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "next/babel"
4 | ],
5 | "plugins": [
6 | [
7 | "styled-components",
8 | {
9 | "ssr": true,
10 | "displayName": true,
11 | "preprocess": false
12 | }
13 | ]
14 | ]
15 | }
--------------------------------------------------------------------------------
/public/close.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icon-light-chevron-down.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/components/LoadingPage/LoadingPage.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Container = styled.section`
4 | width: 100%;
5 | margin: 25vh 0;
6 | display: flex;
7 | flex-direction: column;
8 | justify-content: center;
9 | align-items: center;
10 | `;
11 |
--------------------------------------------------------------------------------
/.env.template:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_CHAIN_ID='71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd'
2 | NEXT_PUBLIC_CHAIN_ENDPOINT='https://testnet.protonchain.com'
3 | NEXT_PUBLIC_BLOCK_EXPLORER='https://proton-test.bloks.io/block/'
4 | NEXT_PUBLIC_NFT_ENDPOINT='https://test.proton.api.atomicassets.io'
5 |
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | image: nikolaik/python-nodejs
2 |
3 | stages:
4 | - test
5 |
6 | test:
7 | image: nikolaik/python-nodejs
8 | stage: test
9 | only:
10 | - merge_requests
11 | script:
12 | - yarn
13 | - yarn format
14 | - yarn typecheck
15 | - yarn lint
16 | - yarn build
17 |
--------------------------------------------------------------------------------
/components/LoadingPage/index.tsx:
--------------------------------------------------------------------------------
1 | import { Container } from './LoadingPage.styled';
2 | import Spinner from '../Spinner';
3 |
4 | const LoadingPage = (): JSX.Element => {
5 | return (
6 |
7 |
8 |
9 | );
10 | };
11 |
12 | export default LoadingPage;
13 |
--------------------------------------------------------------------------------
/components/TableContentWraper/TableContentWrapper.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const BlankRow = styled.tr`
4 | height: 200px;
5 | `;
6 |
7 | export const CenteredCell = styled.td`
8 | text-align: center;
9 | vertical-align: middle;
10 | color: #7578b5;
11 | `;
12 |
--------------------------------------------------------------------------------
/public/loading.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/styles/FadeInImageContainer.styled.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const fadeIn = keyframes`
4 | 0% {
5 | opacity: 0
6 | }
7 | 100% {
8 | opacity: 1
9 | }
10 | `;
11 |
12 | export const FadeInImageContainer = styled.div`
13 | animation: ${fadeIn} 0.5s linear;
14 | `;
15 |
--------------------------------------------------------------------------------
/components/TableRow/index.tsx:
--------------------------------------------------------------------------------
1 | import { StyledTableRow } from './TableRow.styled';
2 | import { ReactNode } from 'react';
3 |
4 | type Props = {
5 | children: ReactNode;
6 | };
7 |
8 | const TableRow = ({ children }: Props): JSX.Element => (
9 | {children}
10 | );
11 | export default TableRow;
12 |
--------------------------------------------------------------------------------
/components/Provider/index.ts:
--------------------------------------------------------------------------------
1 | export { useAuthContext, AuthProvider } from './AuthProvider';
2 | export { useModalContext, ModalProvider, MODAL_TYPES } from './ModalProvider';
3 | export type {
4 | CancelSaleModalProps,
5 | CancelMultipleSalesModalProps,
6 | CreateSaleModalProps,
7 | CreateMultipleSalesModalProps,
8 | } from './ModalProvider';
9 |
--------------------------------------------------------------------------------
/components/TableHeaderCell/index.tsx:
--------------------------------------------------------------------------------
1 | import { StyledTableHeaderCell } from './TableHeaderCell.styled';
2 |
3 | type Props = {
4 | children: string;
5 | };
6 |
7 | const TableHeaderCell = ({ children }: Props): JSX.Element => (
8 | {children}
9 | );
10 |
11 | export default TableHeaderCell;
12 |
--------------------------------------------------------------------------------
/public/arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/styles/Title.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { breakpoint } from './Breakpoints';
3 |
4 | export const Title = styled.h1`
5 | font-family: GilroySemiBold;
6 | font-size: 28px;
7 | line-height: 32px;
8 | color: #0e103c;
9 | margin: 48px 0;
10 |
11 | ${breakpoint.tablet`
12 | margin: 32px 0;
13 | `}
14 | `;
15 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | images: {
3 | domains: ['cloudflare-ipfs.com'],
4 | },
5 | webpack(config) {
6 | config.module.rules.push({
7 | test: /\.svg$/,
8 | issuer: {
9 | test: /\.(js|ts)x?$/,
10 | },
11 | use: ['@svgr/webpack', 'url-loader'],
12 | });
13 |
14 | return config;
15 | },
16 | };
17 |
--------------------------------------------------------------------------------
/components/TableHeaderRow/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { StyledTableHeaderRow } from './TableHeader.styled';
3 |
4 | type Props = {
5 | children: ReactNode;
6 | };
7 |
8 | const TableHeaderRow = ({ children }: Props): JSX.Element => (
9 | {children}
10 | );
11 | export default TableHeaderRow;
12 |
--------------------------------------------------------------------------------
/components/TableDataCell/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import { StyledTableDataCell } from './TableDataCell.styled';
3 |
4 | type Props = {
5 | children: ReactNode;
6 | };
7 |
8 | const TableDataCell = ({ children }: Props): JSX.Element => {
9 | return {children};
10 | };
11 |
12 | export default TableDataCell;
13 |
--------------------------------------------------------------------------------
/components/Spinner/index.tsx:
--------------------------------------------------------------------------------
1 | import StyledSpinner from './Spinner.styled';
2 |
3 | const Spinner = (): JSX.Element => (
4 |
5 |
13 |
14 | );
15 |
16 | export default Spinner;
17 |
--------------------------------------------------------------------------------
/public/down-arrow.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/x.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/letsencrypt-ssl-cert-issuer.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: cert-manager.io/v1
2 | kind: ClusterIssuer
3 | metadata:
4 | name: nft-demo-issuer
5 | spec:
6 | acme:
7 | server: https://acme-v02.api.letsencrypt.org/directory
8 | email: cindy@metalpay.co
9 | privateKeySecretRef:
10 | name: nft-demo-cert
11 | http01: {}
12 | solvers:
13 | - selector: {}
14 | http01:
15 | ingress:
16 | class: nginx
17 |
18 |
--------------------------------------------------------------------------------
/deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: nft-demo
5 | labels:
6 | app: nft-demo
7 | spec:
8 | selector:
9 | matchLabels:
10 | app: nft-demo
11 | template:
12 | metadata:
13 | labels:
14 | app: nft-demo
15 | tier: web
16 | spec:
17 | containers:
18 | - name: nft-demo
19 | image: gcr.io/proton-wallet/nft-demo
20 | ports:
21 | - containerPort: 3000
--------------------------------------------------------------------------------
/styles/MaxWidth.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { breakpoint } from './Breakpoints';
3 |
4 | export const MaxWidth = styled.div`
5 | max-width: 1128px;
6 | display: flex;
7 | justify-content: center;
8 | margin: 0px auto;
9 |
10 | ${breakpoint.laptop`
11 | max-width: 80%;
12 | `}
13 |
14 | ${breakpoint.tablet`
15 | max-width: 90%;
16 | `};
17 |
18 | ${breakpoint.mobile`
19 | max-width: 90%;
20 | `};
21 | `;
22 |
--------------------------------------------------------------------------------
/certificate-storage.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: cert-manager.io/v1
2 | kind: Certificate
3 | metadata:
4 | name: nft-demo-cert
5 | namespace: default
6 | spec:
7 | secretName: nft-demo-cert
8 | issuerRef:
9 | name: nft-demo-issuer
10 | kind: ClusterIssuer
11 | commonName: nft.protonchain.com
12 | dnsNames:
13 | - nft.protonchain.com
14 | acme:
15 | config:
16 | - http01:
17 | ingress: nft-demo-ssl-ingress
18 | domains:
19 | - nft.protonchain.com
20 |
--------------------------------------------------------------------------------
/components/SalesHistoryTableCell/SalesHistoryTabelCell.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import Image from 'next/image';
3 | import { breakpoint } from '../../styles/Breakpoints';
4 |
5 | export const AvatarImage = styled(Image)`
6 | border-radius: 100%;
7 | `;
8 |
9 | export const ImageDataCell = styled.td`
10 | vertical-align: middle;
11 | text-align: ${({ align }) => (align ? align : ' center')};
12 |
13 | ${breakpoint.mobile`
14 | padding: 0px 5px;
15 | `}
16 | `;
17 |
--------------------------------------------------------------------------------
/components/TableDataCell/TableDataCell.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { breakpoint } from '../../styles/Breakpoints';
3 |
4 | export const StyledTableDataCell = styled.td`
5 | display: table-cell;
6 | vertical-align: middle;
7 | font-size: 16px;
8 | font-weight: 500;
9 | font-stretch: normal;
10 | font-style: normal;
11 | line-height: 1.5;
12 | letter-spacing: normal;
13 | color: #0e103c;
14 | text-align: 'left';
15 |
16 | ${breakpoint.mobile`
17 | padding: 0px 10px;
18 | `}
19 | `;
20 |
--------------------------------------------------------------------------------
/.github/workflows/merge-request.yml:
--------------------------------------------------------------------------------
1 | name: merge-request
2 | on:
3 | pull_request:
4 | branches: [ master, develop ]
5 | jobs:
6 | typecheck:
7 | name: Run Prettier, Linter, and Typecheck
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v2
11 | - uses: actions/setup-node@v1
12 | - name: Install packages
13 | run: yarn
14 | - name: Prettier
15 | run: yarn format
16 | - name: Linter
17 | run: yarn lint
18 | - name: Typecheck
19 | run: yarn typecheck
20 |
--------------------------------------------------------------------------------
/components/TableHeaderCell/TableHeaderCell.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { breakpoint } from '../../styles/Breakpoints';
3 |
4 | export const StyledTableHeaderCell = styled.th`
5 | padding: 9px 0px;
6 | font-size: 12px;
7 | font-weight: 600;
8 | font-stretch: normal;
9 | font-style: normal;
10 | line-height: 2;
11 | letter-spacing: 1px;
12 | color: #7578b5;
13 | text-align: left;
14 |
15 | ${breakpoint.mobile`
16 | padding: 9px 10px;
17 | `}
18 |
19 | :first-of-type {
20 | width: 64px;
21 | }
22 | `;
23 |
--------------------------------------------------------------------------------
/components/Grid/Grid.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { breakpoint } from '../../styles/Breakpoints';
3 |
4 | export const Container = styled.section`
5 | width: 100%;
6 | display: inline-grid;
7 | grid-column-gap: 16px;
8 | grid-row-gap: 48px;
9 | grid-template-columns: repeat(5, minmax(0, 1fr));
10 |
11 | ${breakpoint.laptop`
12 | grid-template-columns: repeat(3, minmax(0, 1fr));
13 | `}
14 |
15 | ${breakpoint.tablet`
16 | grid-template-columns: repeat(2, minmax(0, 1fr));
17 | grid-column-gap: 10px;
18 | `}
19 | `;
20 |
--------------------------------------------------------------------------------
/components/PriceInput/PriceInput.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Input = styled.input`
4 | font-size: 14px;
5 | line-height: 24px;
6 | color: #7578b5;
7 | border-radius: 4px;
8 | padding: 8px;
9 | border: solid 1px #e8ecfd;
10 | margin-bottom: 12px;
11 | width: 100%;
12 |
13 | ::-webkit-inner-spin-button,
14 | ::-webkit-outer-spin-button {
15 | -webkit-appearance: none;
16 | -moz-appearance: none;
17 | appearance: none;
18 | margin: 0;
19 | }
20 |
21 | ::placeholder {
22 | color: #7578b5;
23 | }
24 | `;
25 |
--------------------------------------------------------------------------------
/public/launch.svg:
--------------------------------------------------------------------------------
1 |
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | package-lock.json
27 | yarn.lock
28 |
29 | # local env files
30 | .env.local
31 | .env.development.local
32 | .env.test.local
33 | .env.production.local
34 |
35 | # vercel
36 | .vercel
37 |
38 | # jetbrains IDEs
39 | .idea
40 |
--------------------------------------------------------------------------------
/components/Button/index.tsx:
--------------------------------------------------------------------------------
1 | import { StyledButton } from './Button.styled';
2 |
3 | type Props = {
4 | children: string;
5 | onClick: () => void;
6 | filled?: boolean;
7 | rounded?: boolean;
8 | fullWidth?: boolean;
9 | disabled?: boolean;
10 | };
11 |
12 | const Button = ({
13 | children,
14 | onClick,
15 | filled,
16 | rounded,
17 | fullWidth,
18 | disabled,
19 | }: Props): JSX.Element => (
20 |
26 | {children}
27 |
28 | );
29 |
30 | export default Button;
31 |
--------------------------------------------------------------------------------
/utils/constants.ts:
--------------------------------------------------------------------------------
1 | export const EMPTY_BALANCE = '0 FOOBAR';
2 | export const TOKEN_SYMBOL = 'FOOBAR';
3 | export const TOKEN_CONTRACT = 'xtokens';
4 | export const TOKEN_PRECISION = 6;
5 | export const SHORTENED_TOKEN_PRECISION = 2;
6 | export const DEFAULT_COLLECTION = 'monsters';
7 | export const PAGINATION_LIMIT = 10;
8 |
9 | export interface QueryParams {
10 | collection_name?: string;
11 | owner?: string;
12 | state?: string;
13 | sender?: string;
14 | seller?: string;
15 | asset_id?: string;
16 | template_id?: string;
17 | limit?: string | number;
18 | sort?: string;
19 | order?: string;
20 | page?: number;
21 | symbol?: string;
22 | }
23 |
--------------------------------------------------------------------------------
/components/Banner/Banner.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Background = styled.section`
4 | left: 0;
5 | width: 100%;
6 | height: 40px;
7 | background: #ebf4ee;
8 | z-index: 2;
9 | display: flex;
10 | justify-content: center;
11 | align-items: center;
12 | color: #75b587;
13 | position: fixed;
14 | font-size: 14px;
15 | font-family: GilroySemiBold;
16 | cursor: pointer;
17 | `;
18 |
19 | export const Spacer = styled.div`
20 | height: 40px;
21 | `;
22 |
23 | export const Content = styled.span`
24 | position: relative;
25 | color: #75b587;
26 | font-size: 14px;
27 | font-family: GilroySemiBold;
28 | `;
29 |
--------------------------------------------------------------------------------
/components/PageLayout/PageLayout.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { MaxWidth } from '../../styles/MaxWidth.styled';
3 | import { breakpoint } from '../../styles/Breakpoints';
4 |
5 | export const Main = styled.main`
6 | position: relative;
7 | min-height: calc(100vh - 83px);
8 | padding-top: 64px;
9 | overflow: auto;
10 |
11 | ${breakpoint.tablet`
12 | min-height: calc(100vh - 83px);
13 | `}
14 | `;
15 |
16 | export const Container = styled(MaxWidth)`
17 | width: 100%;
18 | flex-direction: column;
19 | justify-content: flex-start;
20 | margin-bottom: 128px;
21 |
22 | ${breakpoint.tablet`
23 | margin-bottom: 64px;
24 | `}
25 | `;
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "downlevelIteration": true
21 | },
22 | "include": [
23 | "next-env.d.ts",
24 | "**/*.ts",
25 | "**/*.tsx",
26 | "next.config.js"
27 | ],
28 | "exclude": [
29 | "node_modules"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/components/Grid/index.tsx:
--------------------------------------------------------------------------------
1 | import { TemplateCard } from '../GridCard';
2 | import { Template } from '../../services/templates';
3 | import { Container } from './Grid.styled';
4 |
5 | type Props = {
6 | items: Template[];
7 | isLoading?: boolean;
8 | isUsersTemplates?: boolean;
9 | };
10 |
11 | const Grid = ({ isLoading, items, isUsersTemplates }: Props): JSX.Element => {
12 | return (
13 |
14 | {items.map((item) => (
15 |
21 | ))}
22 |
23 | );
24 | };
25 |
26 | export default Grid;
27 |
--------------------------------------------------------------------------------
/components/Tooltip/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode, useState } from 'react';
2 | import { Background, Header, Content } from './Tooltip.styled';
3 |
4 | type Props = {
5 | children: ReactNode;
6 | content: string;
7 | };
8 |
9 | const Tooltip = ({ children, content }: Props): JSX.Element => {
10 | const [active, setActive] = useState(false);
11 |
12 | return (
13 | setActive(true)}
15 | onMouseLeave={() => setActive(false)}>
16 | {children}
17 | {active && (
18 |
19 |
20 | {content}
21 |
22 | )}
23 |
24 | );
25 | };
26 |
27 | export default Tooltip;
28 |
--------------------------------------------------------------------------------
/components/PaginationButton/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from './PaginationButton.styled';
2 | import { ReactComponent as Arrow } from '../../public/arrow.svg';
3 | import { ReactComponent as Loading } from '../../public/loading.svg';
4 |
5 | type Props = {
6 | onClick: () => Promise;
7 | disabled: boolean;
8 | isLoading: boolean;
9 | isHidden?: boolean;
10 | };
11 |
12 | const PaginationButton = ({
13 | onClick,
14 | disabled,
15 | isLoading,
16 | isHidden,
17 | }: Props): JSX.Element => (
18 |
25 | );
26 |
27 | export default PaginationButton;
28 |
--------------------------------------------------------------------------------
/components/Spinner/Spinner.styled.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 |
3 | const rotate = keyframes`
4 | 100% {
5 | transform: rotate(360deg);
6 | }
7 | `;
8 |
9 | const dash = keyframes`
10 | 0% {
11 | stroke-dasharray: 1, 150;
12 | stroke-dashoffset: 0;
13 | }
14 | 50% {
15 | stroke-dasharray: 90, 150;
16 | stroke-dashoffset: -35;
17 | }
18 | 100% {
19 | stroke-dasharray: 90, 150;
20 | stroke-dashoffset: -124;
21 | }
22 | `;
23 |
24 | const StyledSpinner = styled.svg`
25 | animation: ${rotate} 2s linear infinite;
26 | width: 50px;
27 | height: 50px;
28 |
29 | & .path {
30 | stroke: rgba(117, 120, 181, 0.2);
31 | stroke-linecap: round;
32 | animation: ${dash} 1.5s ease-in-out infinite;
33 | }
34 | `;
35 |
36 | export default StyledSpinner;
37 |
--------------------------------------------------------------------------------
/ingress-tls.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: nft-demo-backend
5 | labels:
6 | app: nft-demo
7 | spec:
8 | type: NodePort
9 | selector:
10 | app: nft-demo
11 | tier: web
12 | ports:
13 | - port: 3000
14 | targetPort: 3000
15 | ---
16 | apiVersion: extensions/v1beta1
17 | kind: Ingress
18 | metadata:
19 | name: nft-demo-ssl-ingress
20 | annotations:
21 | kubernetes.io/ingress.class: nginx
22 | cert-manager.io/cluster-issuer: nft-demo-issuer
23 | labels:
24 | app: nft-demo
25 | spec:
26 | tls:
27 | - secretName: nft-demo-cert
28 | hosts:
29 | - nft.protonchain.com
30 | rules:
31 | - host: nft.protonchain.com
32 | http:
33 | paths:
34 | - backend:
35 | serviceName: nft-demo-backend
36 | servicePort: 3000
37 | path: /
38 |
--------------------------------------------------------------------------------
/components/AssetFormTitle/AssetFormTitle.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const NameContainer = styled.div`
4 | display: flex;
5 | justify-content: space-between;
6 | `;
7 |
8 | export const Name = styled.h1`
9 | font-size: 40px;
10 | line-height: 48px;
11 | font-family: GilroySemiBold;
12 | margin-bottom: 8px;
13 | `;
14 |
15 | export const General = styled.p`
16 | color: #7578b5;
17 | font-size: 14px;
18 | line-height: 24px;
19 | `;
20 |
21 | export const Title = styled(General)`
22 | margin-left: 8px;
23 | color: #0e103c;
24 | `;
25 |
26 | export const Author = styled(General).attrs({ as: 'a' })`
27 | color: #8a9ef5;
28 | cursor: pointer;
29 | `;
30 |
31 | export const CollectionIconContainer = styled.div`
32 | display: flex;
33 | align-items: center;
34 | margin-bottom: 24px;
35 | `;
36 |
--------------------------------------------------------------------------------
/components/Error/index.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import Button from '../Button';
3 | import { Container, QuestionIcon, Text } from './Error.styled';
4 |
5 | type Props = {
6 | errorMessage: string;
7 | buttonText?: string;
8 | buttonOnClick?: () => void;
9 | };
10 |
11 | const Error = ({
12 | errorMessage,
13 | buttonText,
14 | buttonOnClick,
15 | }: Props): JSX.Element => {
16 | const router = useRouter();
17 | const onClick = buttonOnClick ? buttonOnClick : () => router.push('/');
18 | return (
19 |
20 |
21 | {errorMessage}
22 |
25 |
26 | );
27 | };
28 |
29 | Error.defaultProps = {
30 | buttonText: 'Explore Monsters',
31 | };
32 |
33 | export default Error;
34 |
--------------------------------------------------------------------------------
/components/Error/Error.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Container = styled.section`
4 | width: 100%;
5 | margin: 10% 0;
6 | display: flex;
7 | flex-direction: column;
8 | justify-content: center;
9 | align-items: center;
10 | `;
11 |
12 | export const QuestionIcon = styled.div`
13 | font-family: GilroySemiBold;
14 | font-size: 50px;
15 | width: 94px;
16 | height: 94px;
17 | display: flex;
18 | justify-content: center;
19 | align-items: center;
20 | box-sizing: border-box;
21 | color: #8a9ef5;
22 | background-color: #f1f3fe;
23 | border-radius: 35px;
24 | :before {
25 | content: '?';
26 | }
27 | `;
28 |
29 | export const Text = styled.p`
30 | font-family: GilroySemiBold;
31 | font-size: 18px;
32 | line-height: 24px;
33 | color: #0e103c;
34 | max-width: 280px;
35 | text-align: center;
36 | margin: 32px 0 20px;
37 | `;
38 |
--------------------------------------------------------------------------------
/components/PaginationButton/PaginationButton.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | type ButtonProps = {
4 | isHidden: boolean;
5 | disabled: boolean;
6 | };
7 |
8 | export const Button = styled.button`
9 | ${({ isHidden }) => isHidden && `display: none;`}
10 | padding: 8px 16px;
11 | margin: 24px 0 12px;
12 | border-radius: 4px;
13 | border: 1px solid #e8ecfd;
14 | transition: 0.2s;
15 | width: 100%;
16 | transform: rotate(-180deg);
17 | -webkit-transform: rotate(-180deg);
18 | background-color: #ffffff;
19 |
20 | ${({ disabled }) =>
21 | !disabled &&
22 | `
23 | cursor: pointer;
24 | :hover {
25 | opacity: 1;
26 | color: #ffffff;
27 | background-color: #8a9ef5;
28 | box-shadow: 0 8px 12px -4px rgba(130, 136, 148, 0.24),
29 | 0 0 4px 0 rgba(141, 141, 148, 0.16), 0 0 2px 0 rgba(141, 141, 148, 0.12);
30 | }
31 | `}
32 | `;
33 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:14-alpine
2 |
3 | ENV PORT 3000
4 |
5 | RUN mkdir -p /usr/src/app
6 | WORKDIR /usr/src/app
7 |
8 | ARG NEXT_PUBLIC_GA_TRACKING_ID
9 | ARG NEXT_PUBLIC_CHAIN_ID
10 | ARG NEXT_PUBLIC_CHAIN_ENDPOINT
11 | ARG NEXT_PUBLIC_BLOCK_EXPLORER
12 | ARG NEXT_PUBLIC_NFT_ENDPOINT
13 | ARG XAUTH_PROTON_MARKET
14 | ARG PINATA_API_KEY
15 | ARG PINATA_SECRET
16 | ENV NEXT_PUBLIC_CHAIN_ID=$NEXT_PUBLIC_CHAIN_ID
17 | ENV NEXT_PUBLIC_CHAIN_ENDPOINT=$NEXT_PUBLIC_CHAIN_ENDPOINT
18 | ENV NEXT_PUBLIC_BLOCK_EXPLORER=$NEXT_PUBLIC_BLOCK_EXPLORER
19 | ENV NEXT_PUBLIC_GA_TRACKING_ID=$NEXT_PUBLIC_GA_TRACKING_ID
20 | ENV NEXT_PUBLIC_NFT_ENDPOINT=$NEXT_PUBLIC_NFT_ENDPOINT
21 | ENV XAUTH_PROTON_MARKET=$XAUTH_PROTON_MARKET
22 | ENV PINATA_API_KEY=$PINATA_API_KEY
23 | ENV PINATA_SECRET=$PINATA_SECRET
24 |
25 | COPY package*.json /usr/src/app/
26 | RUN yarn install
27 |
28 | COPY . /usr/src/app
29 |
30 | RUN yarn build
31 |
32 | EXPOSE 3000
33 |
34 | CMD [ "yarn", "start" ]
35 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'GilroyLight';
3 | src: url('/fonts/Gilroy-Light.otf') format('woff');
4 | }
5 |
6 | @font-face {
7 | font-family: 'GilroyExtraBold';
8 | src: url('/fonts/Gilroy-ExtraBold.otf') format('woff');
9 | }
10 |
11 | @font-face {
12 | font-family: 'GilroyMedium';
13 | src: url('/fonts/Gilroy-Medium.otf') format('woff');
14 | }
15 |
16 | @font-face {
17 | font-family: 'GilroySemiBold';
18 | src: url('/fonts/Gilroy-SemiBold.otf') format('woff');
19 | }
20 |
21 | html,
22 | body {
23 | padding: 0;
24 | margin: 0;
25 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
26 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
27 | }
28 |
29 | a {
30 | color: inherit;
31 | text-decoration: none;
32 | }
33 |
34 | * {
35 | box-sizing: border-box;
36 | font-family: 'GilroyMedium';
37 | }
38 |
39 | *:focus-visible {
40 | outline-color: #8a9ef5;
41 | }
42 |
--------------------------------------------------------------------------------
/components/Tooltip/Tooltip.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const Background = styled.div`
4 | position: fixed;
5 | left: 0;
6 | width: 100%;
7 | display: flex;
8 | justify-content: center;
9 | `;
10 |
11 | export const Header = styled.h1`
12 | font-size: 16px;
13 | margin-bottom: 8px;
14 | font-family: GilroyMedium;
15 | color: red;
16 | `;
17 |
18 | export const Content = styled.div`
19 | font-family: GilroyMedium;
20 | font-size: 12px;
21 | color: #7578b5;
22 | flex-direction: column;
23 | max-width: 350px;
24 | padding: 8px;
25 | background-color: white;
26 | border: 1px solid #7578b5;
27 | border-radius: 4px;
28 | margin-top: 48px;
29 | position: absolute;
30 |
31 | ::before {
32 | content: '';
33 | position: absolute;
34 | left: 50%;
35 | border: solid transparent;
36 | top: -11px;
37 | border-width: 5px;
38 | border-bottom-color: #7578b5;
39 | }
40 | `;
41 |
--------------------------------------------------------------------------------
/utils/withCache.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import proton from '../services/proton-rpc';
3 | import cache, { Cache } from '../services/cache';
4 |
5 | export interface MyAssetRequest extends NextApiRequest {
6 | query: {
7 | accounts: string[];
8 | };
9 | cache: Cache;
10 | }
11 |
12 | type Handler = (req: MyAssetRequest, res: NextApiResponse) => Promise;
13 |
14 | export const conditionallyUpdateCache = (
15 | account: string,
16 | cache: Cache
17 | ): Promise =>
18 | new Promise((resolve) => {
19 | if (!cache.has(account)) {
20 | proton.getProfileImage({ account }).then((avatar) => {
21 | cache.set(account, avatar);
22 | resolve(avatar);
23 | });
24 | } else {
25 | resolve(account);
26 | }
27 | });
28 |
29 | const withCache = (handler: Handler): Handler => {
30 | return async (req, res) => {
31 | req.cache = cache;
32 | return handler(req, res);
33 | };
34 | };
35 |
36 | export default withCache;
37 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/Button/Button.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export interface ButtonProps {
4 | filled?: boolean;
5 | rounded?: boolean;
6 | fullWidth?: boolean;
7 | }
8 |
9 | export const StyledButton = styled.button`
10 | padding: 8px 16px;
11 | margin: 12px 0;
12 | border-radius: ${({ rounded }) => (rounded ? '20px' : '4px')};
13 | border: ${({ filled }) => (filled ? 'none' : ' 1px solid #e8ecfd')};
14 | background-color: ${({ filled }) => (filled ? '#8a9ef5' : '#ffffff')};
15 | color: ${({ filled }) => (filled ? '#ffffff' : '#8a9ef5')};
16 | cursor: pointer;
17 | transition: 0.2s;
18 | height: auto;
19 | font-size: 16px;
20 | line-height: 24px;
21 | width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
22 |
23 | :hover,
24 | :focus-visible {
25 | opacity: 1;
26 | color: #ffffff;
27 | background-color: ${({ filled }) => (filled ? '#4d5dc1' : '#8a9ef5')};
28 | box-shadow: 0 8px 12px -4px rgba(130, 136, 148, 0.24),
29 | 0 0 4px 0 rgba(141, 141, 148, 0.16), 0 0 2px 0 rgba(141, 141, 148, 0.12);
30 | }
31 | `;
32 |
--------------------------------------------------------------------------------
/utils/gtag.ts:
--------------------------------------------------------------------------------
1 | export const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GA_TRACKING_ID;
2 | export const isProduction = process.env.NODE_ENV === 'production';
3 |
4 | type Event = {
5 | event_category?: string;
6 | event_label?: string;
7 | page_path?: URL;
8 | value?: number;
9 | };
10 |
11 | declare global {
12 | interface Window {
13 | gtag: (type: string, action: string, event: Event) => void;
14 | }
15 | }
16 |
17 | // https://developers.google.com/analytics/devguides/collection/gtagjs/pages
18 | export const pageview = (url: URL): void => {
19 | if (isProduction) {
20 | window.gtag('config', GA_TRACKING_ID, {
21 | page_path: url,
22 | });
23 | }
24 | };
25 |
26 | type GTagEvent = {
27 | action: string;
28 | category?: string;
29 | label?: string;
30 | value?: number;
31 | };
32 |
33 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events
34 | export const event = ({ action, category, label, value }: GTagEvent): void => {
35 | if (isProduction) {
36 | window.gtag('event', action, {
37 | event_category: category,
38 | event_label: label,
39 | value,
40 | });
41 | }
42 | };
43 |
--------------------------------------------------------------------------------
/styles/Breakpoints.ts:
--------------------------------------------------------------------------------
1 | import { css } from 'styled-components';
2 |
3 | interface MediaQueryProps {
4 | [key: string]: string;
5 | }
6 |
7 | interface MediaQueryValues {
8 | [key: string]: number;
9 | }
10 |
11 | const breakpoints: MediaQueryProps = {
12 | smallMobile: '400px',
13 | mobile: '600px',
14 | tablet: '970px',
15 | laptop: '1224px',
16 | };
17 |
18 | export const breakpointValues: MediaQueryValues = {
19 | smallMobile: 400,
20 | mobile: 600,
21 | tablet: 970,
22 | laptop: 1224,
23 | };
24 |
25 | export const breakpoint = Object.keys(breakpoints).reduce(
26 | (accumulator, label) => {
27 | accumulator[label] = (...args: Array) => {
28 | return css`
29 | @media (max-width: ${breakpoints[label]}) {
30 | ${css({}, ...args)};
31 | }
32 | `;
33 | };
34 | return accumulator;
35 | },
36 | {
37 | smallMobile: undefined,
38 | mobile: undefined,
39 | tablet: undefined,
40 | laptop: undefined,
41 | }
42 | );
43 |
44 | // How to use
45 | // export const ExampleComponent = styled.div`
46 | // background-color: lime;
47 |
48 | // ${breakpoint.mobile`
49 | // background-color: red;
50 | // `}
51 | // `;
52 |
--------------------------------------------------------------------------------
/components/AssetFormBuy/AssetFormBuy.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | export const General = styled.p`
4 | color: #7578b5;
5 | font-size: 14px;
6 | line-height: 24px;
7 | `;
8 |
9 | export const Amount = styled.h3`
10 | font-size: 22px;
11 | font-weight: 600;
12 | line-height: 32px;
13 | margin-bottom: 28px;
14 | `;
15 |
16 | export const Row = styled.div`
17 | display: flex;
18 | justify-content: space-between;
19 | align-items: center;
20 | width: 100%;
21 | `;
22 |
23 | export const ErrorMessage = styled.p`
24 | color: #b57579;
25 | font-size: 16px;
26 | line-height: 24px;
27 | `;
28 |
29 | export const DropdownMenu = styled.select`
30 | font-size: 16px;
31 | margin: 4px 0 12px;
32 | padding: 0 16px;
33 | width: 100%;
34 | height: 40px;
35 | color: #0e103c;
36 | border: 1px solid #e8ecfd;
37 | border-radius: 4px;
38 | cursor: pointer;
39 | line-height: 24px;
40 | position: relative;
41 | -webkit-appearance: none;
42 | -moz-appearance: none;
43 | appearance: none;
44 | background: url('/down-arrow.svg');
45 | background-repeat: no-repeat;
46 | background-position: top 2px right 15px;
47 |
48 | &:hover {
49 | border: 1px solid #aab2d5;
50 | }
51 | `;
52 |
--------------------------------------------------------------------------------
/components/TableContentWraper/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import Spinner from '../Spinner';
3 | import { BlankRow, CenteredCell } from './TableContentWrapper.styled';
4 | import TableRow from '../TableRow';
5 |
6 | type TableContentWrapperProps = {
7 | children: ReactNode;
8 | error?: string;
9 | loading: boolean;
10 | columns: number;
11 | noData: boolean;
12 | noDataMessage?: string;
13 | };
14 |
15 | const TableContentWrapper = ({
16 | children,
17 | error,
18 | loading,
19 | columns,
20 | noData,
21 | noDataMessage = 'No Data',
22 | }: TableContentWrapperProps): JSX.Element => {
23 | if (error) {
24 | return (
25 |
26 | {error}
27 |
28 | );
29 | } else if (loading) {
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | );
37 | } else if (noData) {
38 | return (
39 |
40 | {noDataMessage}
41 |
42 | );
43 | } else {
44 | return <>{children}>;
45 | }
46 | };
47 |
48 | export default TableContentWrapper;
49 |
--------------------------------------------------------------------------------
/pages/api/profile.ts:
--------------------------------------------------------------------------------
1 | import { NextApiResponse } from 'next';
2 | import withCache, {
3 | MyAssetRequest,
4 | conditionallyUpdateCache,
5 | } from '../../utils/withCache';
6 |
7 | const handler = async (
8 | req: MyAssetRequest,
9 | res: NextApiResponse
10 | ): Promise => {
11 | const {
12 | method,
13 | query: { accounts },
14 | } = req;
15 | switch (method) {
16 | case 'POST':
17 | break;
18 | case 'PUT':
19 | break;
20 | case 'PATCH':
21 | break;
22 | default: {
23 | try {
24 | const chainAccounts =
25 | typeof accounts === 'string' ? [accounts] : [...new Set(accounts)];
26 |
27 | const promises = chainAccounts.map((account) =>
28 | conditionallyUpdateCache(account, req.cache)
29 | );
30 | await Promise.all(promises);
31 |
32 | const avatarsByChainAccount = req.cache.getValues(chainAccounts);
33 | res.status(200).send({ success: true, message: avatarsByChainAccount });
34 | } catch (e) {
35 | res.status(500).send({
36 | success: false,
37 | message: e.message || 'Error retrieving profile avatars',
38 | });
39 | }
40 | break;
41 | }
42 | }
43 | };
44 |
45 | export default withCache(handler);
46 |
--------------------------------------------------------------------------------
/styles/reset.css:
--------------------------------------------------------------------------------
1 | /* http://meyerweb.com/eric/tools/css/reset/
2 | v2.0 | 20110126
3 | License: none (public domain)
4 | */
5 |
6 | html, body, div, span, applet, object, iframe,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | a, abbr, acronym, address, big, cite, code,
9 | del, dfn, em, img, ins, kbd, q, s, samp,
10 | small, strike, strong, sub, sup, tt, var,
11 | b, u, i, center,
12 | dl, dt, dd, ol, ul, li,
13 | fieldset, form, label, legend,
14 | table, caption, tbody, tfoot, thead, tr, th, td,
15 | article, aside, canvas, details, embed,
16 | figure, figcaption, footer, header, hgroup,
17 | menu, nav, output, ruby, section, summary,
18 | time, mark, audio, video {
19 | margin: 0;
20 | padding: 0;
21 | border: 0;
22 | font-size: 100%;
23 | vertical-align: baseline;
24 | }
25 | /* HTML5 display-role reset for older browsers */
26 | article, aside, details, figcaption, figure,
27 | footer, header, hgroup, menu, nav, section {
28 | display: block;
29 | }
30 | body {
31 | line-height: 1;
32 | }
33 | ol, ul {
34 | list-style: none;
35 | }
36 | blockquote, q {
37 | quotes: none;
38 | }
39 | blockquote:before, blockquote:after,
40 | q:before, q:after {
41 | content: '';
42 | content: none;
43 | }
44 | table {
45 | border-collapse: collapse;
46 | border-spacing: 0;
47 | }
48 |
--------------------------------------------------------------------------------
/components/Banner/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 | import { useAuthContext, useModalContext } from '../Provider';
3 | import Tooltip from '../Tooltip';
4 | import { Background, Spacer, Content } from './Banner.styled';
5 |
6 | type Props = {
7 | toolTipContent?: string;
8 | bannerContent: string;
9 | modalType: string;
10 | };
11 |
12 | const Banner = ({
13 | toolTipContent,
14 | bannerContent,
15 | modalType,
16 | }: Props): JSX.Element => {
17 | const { currentUser } = useAuthContext();
18 | const { openModal } = useModalContext();
19 | const [isBannerVisible, setIsBannerVisible] = useState(false);
20 |
21 | useEffect(() => {
22 | if (currentUser) {
23 | setIsBannerVisible(true);
24 | } else {
25 | setIsBannerVisible(false);
26 | }
27 | }, [currentUser]);
28 |
29 | const getContent = () => {
30 | return (
31 | openModal(modalType)}>
32 | {bannerContent}
33 |
34 | );
35 | };
36 |
37 | if (!isBannerVisible) return null;
38 |
39 | return (
40 | <>
41 |
42 | {toolTipContent ? (
43 | {getContent()}
44 | ) : (
45 | getContent()
46 | )}
47 | >
48 | );
49 | };
50 |
51 | export default Banner;
52 |
--------------------------------------------------------------------------------
/components/Footer/index.tsx:
--------------------------------------------------------------------------------
1 | import Image from 'next/image';
2 | import {
3 | FooterBackground,
4 | ImageContainer,
5 | StyledFooter,
6 | Section,
7 | FooterLink,
8 | } from './Footer.styled';
9 |
10 | const links = [
11 | {
12 | name: 'Proton Blockchain',
13 | url: 'https://www.protonchain.com/',
14 | },
15 | {
16 | name: 'Documentation',
17 | url: 'https://docs.protonchain.com/',
18 | },
19 | {
20 | name: 'Wallet',
21 | url: 'https://www.protonchain.com/wallet',
22 | },
23 | {
24 | name: 'Support',
25 | url: 'https://support.protonchain.com/support/tickets/new',
26 | },
27 | ];
28 |
29 | const Footer = (): JSX.Element => {
30 | return (
31 |
32 |
33 |
34 |
42 |
43 |
44 | {links.map(({ name, url }) => (
45 |
46 | {name}
47 |
48 | ))}
49 |
50 |
51 |
52 | );
53 | };
54 |
55 | export default Footer;
56 |
--------------------------------------------------------------------------------
/components/Footer/Footer.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { MaxWidth } from '../../styles/MaxWidth.styled';
3 | import { breakpoint } from '../../styles/Breakpoints';
4 | import { FadeInImageContainer } from '../../styles/FadeInImageContainer.styled';
5 |
6 | export const FooterBackground = styled.footer`
7 | width: 100%;
8 | background: #f6f7fe;
9 | border-bottom: 1px solid #f6f7fe;
10 | `;
11 |
12 | export const StyledFooter = styled(MaxWidth).attrs({ as: 'section' })`
13 | justify-content: space-between;
14 | align-items: center;
15 | width: 100%;
16 |
17 | ${breakpoint.tablet`
18 | flex-direction: column;
19 | justify-content: center;
20 | `}
21 | `;
22 |
23 | export const ImageContainer = styled(FadeInImageContainer)`
24 | margin: 24px 0;
25 | `;
26 |
27 | export const Section = styled.section`
28 | display: flex;
29 | justify-content: space-between;
30 |
31 | ${breakpoint.tablet`
32 | flex-direction: column;
33 | justify-content: center;
34 | `}
35 | `;
36 |
37 | export const FooterLink = styled.a`
38 | color: #7578b5;
39 | font-weight: 500;
40 | cursor: pointer;
41 | margin-right: 40px;
42 | padding: 20px 0;
43 | font-size: 16px;
44 | line-height: 24px;
45 | text-align: center;
46 |
47 | ${breakpoint.tablet`
48 | padding: 0;
49 | margin: 0 0 16px;
50 |
51 | &:last-of-type {
52 | margin-bottom: 24px;
53 | }
54 | `}
55 | `;
56 |
--------------------------------------------------------------------------------
/utils/browser-fetch.ts:
--------------------------------------------------------------------------------
1 | export interface BrowserResponse {
2 | success: boolean;
3 | message: T;
4 | data?: T;
5 | error?: {
6 | message: string;
7 | };
8 | }
9 |
10 | const request = (
11 | url: string,
12 | body: P | null,
13 | headers?: HeadersInit | undefined,
14 | method?: string
15 | ): Promise> => {
16 | const isValidBody = body && Object.keys(body).length > 0;
17 | const isValidHeader = headers && Object.keys(headers).length > 0;
18 | const originalHeaders = {
19 | Accept: 'application/json',
20 | 'Content-Type': 'application/json',
21 | };
22 |
23 | const options: RequestInit = {
24 | method: method || 'GET',
25 | body: isValidBody ? JSON.stringify(body) : null,
26 | headers: isValidHeader
27 | ? Object.assign(originalHeaders, headers)
28 | : originalHeaders,
29 | };
30 |
31 | return fetch(url, options)
32 | .then((result) => {
33 | return result.json();
34 | })
35 | .catch((e) => {
36 | throw Error(e);
37 | });
38 | };
39 |
40 | export const sendToApi = (
41 | method: string,
42 | url: string,
43 | body: P,
44 | header?: HeadersInit | undefined
45 | ): Promise> => {
46 | return request(url, body, header, method);
47 | };
48 |
49 | export const getFromApi = (
50 | url: string,
51 | header?: HeadersInit | undefined
52 | ): Promise> => {
53 | return request(url, null, header, 'GET');
54 | };
55 |
--------------------------------------------------------------------------------
/components/AssetFormSellPopupMenu/AssetFormSellPopupMenu.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { breakpoint } from '../../styles/Breakpoints';
3 |
4 | type MenuProps = {
5 | isOpen: boolean;
6 | };
7 |
8 | export const MenuContainer = styled.div`
9 | position: relative;
10 | `;
11 |
12 | export const PopupMenuButton = styled.button`
13 | background: none;
14 | outline: none;
15 | border: 1px solid #e6e6e6;
16 | border-radius: 100%;
17 | width: 40px;
18 | height: 40px;
19 | text-align: center;
20 | cursor: pointer;
21 | transition: 0.2s;
22 |
23 | :hover,
24 | :focus-visible {
25 | background: #efefef;
26 | }
27 | `;
28 |
29 | export const Menu = styled.ul`
30 | display: ${({ isOpen }) => (isOpen ? 'flex' : 'none')};
31 | flex-direction: column;
32 | position: absolute;
33 | top: 0;
34 | left: 50px;
35 | background: #ffffff;
36 | border-radius: 8px;
37 | box-shadow: 0 12px 20px -4px rgba(0, 0, 0, 0.1), 0 0 8px 0 rgba(0, 0, 0, 0.08);
38 | width: 224px;
39 | z-index: 2;
40 |
41 | ${breakpoint.tablet`
42 | left: -235px;
43 | `}
44 | `;
45 |
46 | export const MenuItem = styled.li`
47 | line-height: 24px;
48 | padding: 8px 16px;
49 | cursor: pointer;
50 | transition: 0.2s;
51 | color: #0e103c;
52 | font-size: 16px;
53 |
54 | :hover,
55 | :focus-visible {
56 | color: #8a9ef5;
57 | }
58 |
59 | :first-of-type {
60 | margin-top: 16px;
61 | }
62 |
63 | :last-of-type {
64 | margin-bottom: 16px;
65 | }
66 | `;
67 |
--------------------------------------------------------------------------------
/components/SalesHistoryTableCell/index.tsx:
--------------------------------------------------------------------------------
1 | import TableDataCell from '../TableDataCell';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 | import { AvatarImage, ImageDataCell } from './SalesHistoryTabelCell.styled';
5 |
6 | type Props = {
7 | id: string;
8 | content: string;
9 | };
10 |
11 | const SalesHistoryTableCell = ({ id, content }: Props): JSX.Element => {
12 | switch (id) {
13 | case 'img': {
14 | return (
15 |
16 |
26 |
27 | );
28 | }
29 | case 'serial': {
30 | return #{content};
31 | }
32 | case 'tx': {
33 | return (
34 |
35 |
38 |
39 |
46 |
47 |
48 |
49 | );
50 | }
51 | default: {
52 | return {content};
53 | }
54 | }
55 | };
56 |
57 | export default SalesHistoryTableCell;
58 |
--------------------------------------------------------------------------------
/services/cache.ts:
--------------------------------------------------------------------------------
1 | interface CacheValue {
2 | value: string;
3 | updatedAt: number;
4 | }
5 |
6 | export class Cache {
7 | cache: Map;
8 | maxLength: number;
9 | length: number;
10 |
11 | constructor() {
12 | this.cache = new Map();
13 | this.maxLength = 1000;
14 | this.length = 0;
15 | }
16 |
17 | has(key: string): boolean {
18 | return this.cache.has(key);
19 | }
20 |
21 | set(key: string, value: string): number {
22 | if (this.length >= this.maxLength) {
23 | const leastUsedKey = this.leastRecentlyUsed();
24 | this.delete(leastUsedKey);
25 | }
26 |
27 | if (!this.has(key)) this.length += 1;
28 | this.cache.set(key, {
29 | value,
30 | updatedAt: Date.now(),
31 | });
32 |
33 | return this.length;
34 | }
35 |
36 | getValue(key: string): string {
37 | const { value } = this.cache.get(key);
38 | this.set(key, value);
39 | return value;
40 | }
41 |
42 | delete(key: string): number {
43 | this.length -= 1;
44 | this.cache.delete(key);
45 | return this.length;
46 | }
47 |
48 | clear(): number {
49 | this.length = 0;
50 | this.cache.clear();
51 | return this.length;
52 | }
53 |
54 | leastRecentlyUsed(): string {
55 | const leastUsedKey = Array.from(this.cache.keys()).sort(
56 | (a, b) => this.cache.get(a).updatedAt - this.cache.get(b).updatedAt
57 | )[0];
58 | return leastUsedKey;
59 | }
60 |
61 | getValues(keys: string[]): { [key: string]: string } {
62 | const cacheValue = {};
63 |
64 | for (const key of keys) {
65 | cacheValue[key] = this.getValue(key);
66 | }
67 |
68 | return cacheValue;
69 | }
70 | }
71 |
72 | export default new Cache();
73 |
--------------------------------------------------------------------------------
/components/AssetFormTitle/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import Image from 'next/image';
3 | import { useRouter } from 'next/router';
4 | import {
5 | NameContainer,
6 | Name,
7 | General,
8 | Title,
9 | Author,
10 | CollectionIconContainer,
11 | } from './AssetFormTitle.styled';
12 | import AssetFormSellPopupMenu from '../AssetFormSellPopupMenu';
13 | import { capitalize } from '../../utils';
14 |
15 | type Props = {
16 | templateName: string;
17 | collectionName: string;
18 | collectionAuthor: string;
19 | };
20 |
21 | const AssetFormTitle = ({
22 | templateName,
23 | collectionName,
24 | collectionAuthor,
25 | }: Props): JSX.Element => {
26 | const router = useRouter();
27 | const isMyTemplate = router.pathname.includes('my-templates');
28 | const redirectToAuthor = () => router.push(`/my-nfts/${collectionAuthor}`);
29 |
30 | useEffect(() => {
31 | router.prefetch(`/my-nfts/${collectionAuthor}`);
32 | }, []);
33 |
34 | return (
35 | <>
36 |
37 |
45 | Crypto {capitalize(collectionName)}
46 |
47 |
48 | {templateName}
49 | {isMyTemplate && }
50 |
51 |
52 | Created by{' '}
53 |
54 | {capitalize(collectionAuthor)}
55 |
56 |
57 | >
58 | );
59 | };
60 |
61 | export default AssetFormTitle;
62 |
--------------------------------------------------------------------------------
/components/PageLayout/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode } from 'react';
2 | import Head from 'next/head';
3 | import Router from 'next/router';
4 | import { Main, Container } from './PageLayout.styled';
5 | import { useModalContext, MODAL_TYPES } from '../Provider';
6 | import {
7 | ClaimBalanceModal,
8 | CreateSaleModal,
9 | CreateMultipleSalesModal,
10 | CancelSaleModal,
11 | CancelMultipleSalesModal,
12 | } from '../Modal';
13 | import { useEscapeKeyClose } from '../../hooks';
14 |
15 | type Props = {
16 | title: string;
17 | children: ReactNode;
18 | };
19 |
20 | const PageLayout = ({ title, children }: Props): JSX.Element => {
21 | const { closeModal, modalType } = useModalContext();
22 | useEscapeKeyClose(closeModal);
23 |
24 | Router.events.on('routeChangeComplete', () => {
25 | window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
26 | });
27 |
28 | const renderModal = () => {
29 | switch (modalType) {
30 | case MODAL_TYPES.CLAIM:
31 | return ;
32 | case MODAL_TYPES.CREATE_SALE:
33 | return ;
34 | case MODAL_TYPES.CREATE_MULTIPLE_SALES:
35 | return ;
36 | case MODAL_TYPES.CANCEL_SALE:
37 | return ;
38 | case MODAL_TYPES.CANCEL_MULTIPLE_SALES:
39 | return ;
40 | default:
41 | return null;
42 | }
43 | };
44 |
45 | return (
46 |
47 |
48 | {`${title} - NFT Demo`}
49 |
50 |
51 | {children}
52 | {renderModal()}
53 |
54 | );
55 | };
56 |
57 | export default PageLayout;
58 |
--------------------------------------------------------------------------------
/services/offers.ts:
--------------------------------------------------------------------------------
1 | import { Asset } from './assets';
2 | import { toQueryString } from '../utils';
3 | import { getFromApi } from '../utils/browser-fetch';
4 |
5 | export type Offer = {
6 | contract: string;
7 | offer_id: string;
8 | sender_name: string;
9 | recipient_name: string;
10 | memo: string;
11 | state: number;
12 | sender_assets: Asset[];
13 | recipient_assets: Asset[];
14 | is_sender_contract: boolean;
15 | is_recipient_contract: boolean;
16 | updated_at_block: string;
17 | updated_at_time: string;
18 | created_at_block: string;
19 | created_at_time: string;
20 | };
21 |
22 | /**
23 | * Get list of assets that user has listed for sale
24 | * @param sender The account name of the owner of the assets to look up
25 | * @returns {Offer[]}
26 | */
27 |
28 | export const getUserOffers = async (sender: string): Promise => {
29 | try {
30 | const limit = 100;
31 | let offers = [];
32 | let hasResults = true;
33 | let page = 1;
34 |
35 | while (hasResults) {
36 | const queryObject = {
37 | sender,
38 | state: '0', // Offer created and valid
39 | page,
40 | limit,
41 | };
42 | const queryParams = toQueryString(queryObject);
43 | const result = await getFromApi(
44 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/offers?${queryParams}`
45 | );
46 |
47 | if (!result.success) {
48 | throw new Error((result.message as unknown) as string);
49 | }
50 |
51 | if (result.data.length < limit) {
52 | hasResults = false;
53 | }
54 |
55 | offers = offers.concat(result.data);
56 | page += 1;
57 | }
58 |
59 | return offers;
60 | } catch (e) {
61 | throw new Error(e);
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import type { AppProps } from 'next/app';
4 | import Router from 'next/router';
5 | import NProgress from 'nprogress';
6 | import '../styles/reset.css';
7 | import '../styles/globals.css';
8 | import NavBar from '../components/NavBar';
9 | import Footer from '../components/Footer';
10 | import { AuthProvider, ModalProvider } from '../components/Provider';
11 | import '../styles/customprogress.css';
12 | import * as gtag from '../utils/gtag';
13 |
14 | NProgress.configure({
15 | minimum: 0.3,
16 | easing: 'ease',
17 | speed: 800,
18 | showSpinner: false,
19 | });
20 |
21 | function MyApp({ Component, pageProps }) {
22 | const start = () => NProgress.start();
23 | const end = (url) => {
24 | NProgress.done();
25 | gtag.pageview(url);
26 | };
27 |
28 | /*
29 | useEffect(() => {
30 | if (process.env.NODE_ENV !== 'production') {
31 | // eslint-disable-next-line @typescript-eslint/no-var-requires
32 | const axe = require('@axe-core/react');
33 | axe(React, ReactDOM, 1000);
34 | }
35 | }, []);
36 | */
37 |
38 | useEffect(() => {
39 | Router.events.on('routeChangeStart', start);
40 | Router.events.on('routeChangeComplete', end);
41 | Router.events.on('routeChangeError', end);
42 | return () => {
43 | Router.events.off('routeChangeStart', start);
44 | Router.events.off('routeChangeComplete', end);
45 | Router.events.off('routeChangeError', end);
46 | };
47 | }, []);
48 |
49 | return (
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | export default MyApp;
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nft-demo",
3 | "version": "0.0.1",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "eslint --fix .",
10 | "format": "prettier --write './**/*.{js,jsx, ts, tsx}' --config ./.prettierrc.js",
11 | "typecheck": "$(npm bin)/tsc --noEmit",
12 | "check:all": "yarn lint && yarn format && yarn typecheck"
13 | },
14 | "dependencies": {
15 | "@proton/js": "22.0.45",
16 | "@proton/web-sdk": "2.7.19",
17 | "@svgr/webpack": "^5.5.0",
18 | "@types/styled-components": "^5.1.7",
19 | "cors": "^2.8.5",
20 | "dayjs": "^1.10.4",
21 | "form-data": "^4.0.0",
22 | "multer": "^1.4.2",
23 | "next": "10.0.4",
24 | "nprogress": "^0.2.0",
25 | "react": "17.0.1",
26 | "react-dom": "17.0.1",
27 | "styled-components": "5.2.1"
28 | },
29 | "devDependencies": {
30 | "@axe-core/react": "^4.1.1",
31 | "@babel/core": "^7.12.10",
32 | "@babel/preset-env": "^7.12.11",
33 | "@babel/preset-react": "^7.12.10",
34 | "@types/gtag.js": "0.0.4",
35 | "@types/jest": "^26.0.20",
36 | "@types/node": "^14.14.20",
37 | "@types/react": "^17.0.0",
38 | "@types/react-dom": "^17.0.0",
39 | "@typescript-eslint/eslint-plugin": "^4.12.0",
40 | "@typescript-eslint/parser": "^4.12.0",
41 | "babel-jest": "^26.6.3",
42 | "babel-plugin-styled-components": "^1.12.0",
43 | "eslint": "^7.17.0",
44 | "eslint-config-prettier": "^7.1.0",
45 | "eslint-plugin-jsx-a11y": "^6.4.1",
46 | "eslint-plugin-prettier": "^3.3.1",
47 | "eslint-plugin-react": "^7.22.0",
48 | "eslint-plugin-react-hooks": "^4.2.0",
49 | "prettier": "2.2.1",
50 | "ts-jest": "^26.4.4",
51 | "typescript": "^4.1.3",
52 | "url-loader": "^4.1.1"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/public/placeholder-asset.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/hooks/index.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from 'react';
2 |
3 | export const useScrollLock = (isActive: boolean): void => {
4 | useEffect(() => {
5 | const isWindowsOS =
6 | navigator &&
7 | navigator.platform &&
8 | navigator.platform.toLowerCase().includes('win');
9 | if (isActive && !isWindowsOS) {
10 | document.body.style.overflow = 'hidden';
11 | } else {
12 | document.body.style.overflow = 'scroll';
13 | }
14 | }, [isActive]);
15 | };
16 |
17 | export const usePrevious = (value: string): string => {
18 | const ref = useRef();
19 | useEffect(() => {
20 | ref.current = value;
21 | }, [value]);
22 | return ref.current as string;
23 | };
24 |
25 | export const useWindowSize = (): {
26 | windowWidth: number;
27 | isMobile: boolean;
28 | } => {
29 | const isSSR = typeof window === 'undefined';
30 | const [windowWidth, setWindowWidth] = useState(
31 | isSSR ? 1200 : window.innerWidth
32 | );
33 | const [isMobile, setIsMobile] = useState(
34 | isSSR ? false : window.innerWidth < 600
35 | );
36 |
37 | function changeWindowSize() {
38 | setWindowWidth(window.innerWidth);
39 | if (window.innerWidth < 600) {
40 | setIsMobile(true);
41 | } else {
42 | setIsMobile(false);
43 | }
44 | }
45 |
46 | useEffect(() => {
47 | window.addEventListener('resize', changeWindowSize);
48 |
49 | return () => {
50 | window.removeEventListener('resize', changeWindowSize);
51 | };
52 | }, []);
53 |
54 | return { windowWidth, isMobile };
55 | };
56 |
57 | export const useEscapeKeyClose = (close: () => void): void => {
58 | const closeOnEscape = (e: KeyboardEvent) => {
59 | if (e.key === 'Escape') {
60 | close();
61 | }
62 | };
63 |
64 | useEffect(() => {
65 | window.addEventListener('keydown', closeOnEscape);
66 | return () => {
67 | window.removeEventListener('keydown', closeOnEscape);
68 | };
69 | }, []);
70 | };
71 |
--------------------------------------------------------------------------------
/pages/_document.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
2 | import Document, {
3 | Html,
4 | Head,
5 | Main,
6 | NextScript,
7 | DocumentContext,
8 | } from 'next/document';
9 | import { ServerStyleSheet } from 'styled-components';
10 | import * as gtag from '../utils/gtag';
11 |
12 | export default class MyDocument extends Document {
13 | render() {
14 | return (
15 |
16 |
17 | {gtag.isProduction && (
18 | <>
19 |
23 |
33 | >
34 | )}
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 |
44 | static async getInitialProps(ctx) {
45 | const sheet = new ServerStyleSheet();
46 | const originalRenderPage = ctx.renderPage;
47 |
48 | try {
49 | ctx.renderPage = () =>
50 | originalRenderPage({
51 | enhanceApp: (App) => (props) =>
52 | sheet.collectStyles(),
53 | });
54 |
55 | const initialProps = await Document.getInitialProps(ctx);
56 | return {
57 | ...initialProps,
58 | styles: (
59 | <>
60 | {initialProps.styles}
61 | {sheet.getStyleElement()}
62 | >
63 | ),
64 | };
65 | } finally {
66 | sheet.seal();
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/components/DetailsLayout/DetailsLayout.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { breakpoint } from '../../styles/Breakpoints';
3 | import { FadeInImageContainer } from '../../styles/FadeInImageContainer.styled';
4 |
5 | type ArrowProps = {
6 | isActive: boolean;
7 | };
8 |
9 | type ToggleContainerProps = {
10 | active: boolean;
11 | };
12 |
13 | export const Container = styled.div`
14 | display: flex;
15 | flex-direction: column;
16 | margin: 64px 100px 0px;
17 |
18 | ${breakpoint.tablet`
19 | margin: 64px 0 0;
20 | `};
21 |
22 | ${breakpoint.mobile`
23 | margin: 32px 0 0;
24 | `};
25 | `;
26 |
27 | export const Row = styled.div`
28 | width: 100%;
29 | display: flex;
30 |
31 | ${breakpoint.mobile`
32 | flex-direction: column;
33 | `}
34 | `;
35 |
36 | export const Column = styled.div`
37 | width: 100%;
38 | display: flex;
39 | flex-direction: column;
40 | margin: 0 64px 0 56px;
41 |
42 | ${breakpoint.mobile`
43 | margin: 0;
44 | `}
45 | `;
46 |
47 | export const ImageContainer = styled(FadeInImageContainer)`
48 | width: 100%;
49 |
50 | ${breakpoint.tablet`
51 | max-width: 294px;
52 | `};
53 |
54 | ${breakpoint.mobile`
55 | margin: 32px auto;
56 | `};
57 | `;
58 |
59 | export const Title = styled.h1`
60 | font-size: 22px;
61 | line-height: 32px;
62 | font-family: GilroySemiBold;
63 | `;
64 |
65 | export const ContentRow = styled.div`
66 | display: flex;
67 | justify-content: space-between;
68 | align-items: center;
69 | margin: 48px 0 16px;
70 | `;
71 |
72 | export const ArrowContainer = styled.div`
73 | transform: ${({ isActive }) =>
74 | isActive ? 'rotate(0deg)' : 'rotate(-180deg)'};
75 | -webkit-transform: ${({ isActive }) =>
76 | isActive ? 'rotate(0deg)' : 'rotate(-180deg)'};
77 | cursor: pointer;
78 | `;
79 |
80 | export const ToggleContainer = styled.div`
81 | display: ${({ active }) => (active ? 'block' : 'none')};
82 | width: 100%;
83 | `;
84 |
85 | export const Divider = styled.div`
86 | margin: 24px 0;
87 | border-bottom: 1px solid #e8ecfd;
88 | `;
89 |
--------------------------------------------------------------------------------
/components/GridCard/GridCard.styled.ts:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from 'styled-components';
2 | import { FadeInImageContainer } from '../../styles/FadeInImageContainer.styled';
3 |
4 | const placeHolderShimmer = keyframes`
5 | 0% {
6 | background-position: -500px 0
7 | }
8 | 100% {
9 | background-position: 500px 0
10 | }
11 | `;
12 |
13 | export const Container = styled.article`
14 | display: flex;
15 | flex-direction: column;
16 | width: 100%;
17 | cursor: pointer;
18 | outline: none;
19 |
20 | :hover img,
21 | :focus-visible img {
22 | transition: 0.1s;
23 | transform: scale(1.1);
24 | }
25 | `;
26 |
27 | export const ImageContainer = styled(FadeInImageContainer)`
28 | position: relative;
29 | border-radius: 8px;
30 | overflow: hidden;
31 | backface-visibility: hidden;
32 | transform: translate3d(0, 0, 0);
33 | -webkit-backface-visibility: hidden;
34 | -moz-backface-visibility: hidden;
35 | -webkit-transform: translate3d(0, 0, 0);
36 | -moz-transform: translate3d(0, 0, 0);
37 | `;
38 |
39 | export const Text = styled.span`
40 | font-family: GilroySemiBold;
41 | font-size: 18px;
42 | line-height: 24px;
43 | color: #0e103c;
44 | margin-top: 16px;
45 | `;
46 |
47 | export const SecondaryText = styled.span`
48 | font-size: 12px;
49 | line-height: 2;
50 | color: #7578b5;
51 | `;
52 |
53 | export const Price = styled(Text)`
54 | font-size: 14px;
55 | font-weight: 600;
56 | line-height: 1.71;
57 | margin-top: 6px;
58 | `;
59 |
60 | export const Tag = styled.div`
61 | font-family: GilroySemiBold;
62 | font-size: 12px;
63 | line-height: 24px;
64 | position: absolute;
65 | bottom: 0;
66 | margin: 10px;
67 | padding: 2px 12px;
68 | opacity: 0.6;
69 | border-radius: 4px;
70 | background-color: #0e103c;
71 | color: #ffffff;
72 | `;
73 |
74 | export const EmptyPrice = styled.div`
75 | margin: 15px 0 7px;
76 | height: 18px;
77 | width: 50%;
78 | `;
79 |
80 | export const ShimmerBlock = styled(EmptyPrice)`
81 | animation: ${placeHolderShimmer} 1s linear infinite;
82 | background: linear-gradient(to right, #eeeeee 8%, #e7e7e7 18%, #eeeeee 33%);
83 | background-size: 1000px 18px;
84 | `;
85 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": {
4 | "ecmaFeatures": {
5 | "jsx": true
6 | },
7 | "ecmaVersion": 12,
8 | "sourceType": "module"
9 | },
10 | "env": {
11 | "browser": true,
12 | "es2021": true,
13 | "jest": true,
14 | "node": true
15 | },
16 | "extends": [
17 | "eslint:recommended",
18 | "plugin:@typescript-eslint/eslint-recommended",
19 | "plugin:@typescript-eslint/recommended",
20 | "plugin:react/recommended",
21 | "plugin:jsx-a11y/recommended",
22 | "plugin:react-hooks/recommended",
23 | "prettier/@typescript-eslint",
24 | "plugin:prettier/recommended" // keep this last so prettier config overrides other formatting rules
25 | ],
26 | "plugins": [
27 | "react"
28 | ],
29 | "rules": {
30 | "react-hooks/exhaustive-deps": "off",
31 | "react/react-in-jsx-scope": "off",
32 | "react/prop-types": "off",
33 | "@typescript-eslint/no-empty-function": "off",
34 | "@typescript-eslint/explicit-function-return-type": "off",
35 | "prettier/prettier": ["error", {}, { "usePrettierrc": true }], // Use our .prettierrc file as source
36 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], // ignore args with _ (needed for React.forwardRef fix
37 | "jsx-a11y/accessible-emoji": "off",
38 | "jsx-a11y/anchor-is-valid": [
39 | "error",
40 | {
41 | "components": ["Link"],
42 | "specialLink": ["hrefLeft", "hrefRight"],
43 | "aspects": ["invalidHref", "preferButton"]
44 | }
45 | ],
46 | "prefer-destructuring": ["error", {
47 | "VariableDeclarator": {
48 | "array": false,
49 | "object": true
50 | },
51 | "AssignmentExpression": {
52 | "array": true,
53 | "object": true
54 | }
55 | }, {
56 | "enforceForRenamedProperties": false
57 | }]
58 | },
59 | "globals": {
60 | "React": "writable"
61 | },
62 | "settings": {
63 | "react": {
64 | "version": "17.0.1"
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/components/Modal/ClaimBalanceModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, MouseEvent } from 'react';
2 | import { useAuthContext, useModalContext } from '../Provider';
3 | import {
4 | Background,
5 | ModalBox,
6 | Section,
7 | CloseIconContainer,
8 | Title,
9 | Description,
10 | Row,
11 | Spacer,
12 | HalfButton,
13 | } from './Modal.styled';
14 | import ProtonSDK from '../../services/proton';
15 | import { formatPrice } from '../../utils';
16 | import { ReactComponent as CloseIcon } from '../../public/close.svg';
17 |
18 | export const ClaimBalanceModal = (): JSX.Element => {
19 | const {
20 | currentUser,
21 | atomicMarketBalance,
22 | updateAtomicBalance,
23 | } = useAuthContext();
24 | const { closeModal } = useModalContext();
25 | const [error, setError] = useState('');
26 |
27 | useEffect(() => {
28 | if (error) setError('');
29 | }, []);
30 |
31 | const withdraw = async () => {
32 | try {
33 | const res = await ProtonSDK.withdraw({
34 | actor: currentUser ? currentUser.actor : '',
35 | amount: atomicMarketBalance,
36 | });
37 |
38 | if (!res.success) {
39 | throw new Error('Unable to make withdrawal.');
40 | }
41 |
42 | closeModal();
43 | await updateAtomicBalance(currentUser.actor);
44 | } catch (err) {
45 | setError(err.message);
46 | }
47 | };
48 |
49 | const handleBackgroundClick = (e: MouseEvent) => {
50 | if (e.target === e.currentTarget) {
51 | closeModal();
52 | }
53 | };
54 |
55 | return (
56 |
57 |
58 |
59 | Claim {formatPrice(atomicMarketBalance)}
60 |
61 |
62 |
63 |
64 |
65 | Congratulations, You sold {formatPrice(atomicMarketBalance)} of NFTs.
66 | Claim them now!
67 |
68 |
69 |
70 |
71 | Claim Now
72 |
73 |
74 |
75 |
76 | );
77 | };
78 |
--------------------------------------------------------------------------------
/components/PriceInput/index.tsx:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, KeyboardEvent, Dispatch, SetStateAction } from 'react';
2 | import { Input } from './PriceInput.styled';
3 | import { TOKEN_PRECISION } from '../../utils/constants';
4 |
5 | type Props = {
6 | setAmount: Dispatch>;
7 | amount: string;
8 | placeholder: string;
9 | submit: () => Promise;
10 | };
11 |
12 | const PriceInput = ({
13 | setAmount,
14 | amount,
15 | placeholder,
16 | submit,
17 | }: Props): JSX.Element => {
18 | const updateNumber = (e: ChangeEvent) => {
19 | const inputAmount = e.target.value;
20 | const floatAmount = parseFloat(inputAmount);
21 | const formattedAmount = floatAmount.toFixed(TOKEN_PRECISION);
22 |
23 | if (floatAmount < 0) {
24 | setAmount('0');
25 | return;
26 | }
27 |
28 | if (floatAmount > 1000000000) {
29 | setAmount('1000000000');
30 | return;
31 | }
32 |
33 | if (inputAmount.length > formattedAmount.length) {
34 | setAmount(formattedAmount);
35 | return;
36 | }
37 |
38 | setAmount(inputAmount);
39 | };
40 |
41 | const formatNumber = () => {
42 | const numberAmount = parseFloat(amount).toFixed(TOKEN_PRECISION);
43 | setAmount(numberAmount);
44 | };
45 |
46 | const ignoreInvalidCharacters = (e: KeyboardEvent) => {
47 | const validChars = [
48 | '0',
49 | '1',
50 | '2',
51 | '3',
52 | '4',
53 | '5',
54 | '6',
55 | '7',
56 | '8',
57 | '9',
58 | '.',
59 | 'Enter',
60 | 'Backspace',
61 | 'ArrowLeft',
62 | 'ArrowRight',
63 | 'Tab',
64 | ];
65 |
66 | if (!validChars.includes(e.key) && !e.metaKey) {
67 | e.preventDefault();
68 | }
69 |
70 | if (e.key === 'Enter') {
71 | submit();
72 | }
73 | };
74 |
75 | return (
76 |
89 | );
90 | };
91 |
92 | export default PriceInput;
93 |
--------------------------------------------------------------------------------
/components/AssetFormSell/index.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, Dispatch, SetStateAction } from 'react';
2 | import Button from '../Button';
3 | import {
4 | DropdownMenu,
5 | General,
6 | Amount,
7 | Row,
8 | } from '../AssetFormBuy/AssetFormBuy.styled';
9 | import { Asset } from '../../services/assets';
10 |
11 | type Props = {
12 | dropdownAssets: Asset[];
13 | lowestPrice: string;
14 | maxSupply: string;
15 | buttonText: string;
16 | assetId: string;
17 | handleButtonClick: () => void;
18 | setCurrentAsset: Dispatch>;
19 | };
20 |
21 | export const AssetFormSell = ({
22 | dropdownAssets,
23 | lowestPrice,
24 | maxSupply,
25 | buttonText,
26 | assetId,
27 | handleButtonClick,
28 | setCurrentAsset,
29 | }: Props): JSX.Element => {
30 | useEffect(() => {
31 | handleDropdownSelect(dropdownAssets[0].asset_id);
32 | }, []);
33 |
34 | const handleDropdownSelect = (id: string) => {
35 | const dropdownAsset = dropdownAssets.find((asset) => {
36 | return asset.asset_id === id;
37 | });
38 | setCurrentAsset(dropdownAsset);
39 | };
40 |
41 | return (
42 |
43 |
44 | Lowest Market Price
45 | Edition Size
46 |
47 |
48 | {lowestPrice || 'None'}
49 | {maxSupply}
50 |
51 | Serial number
52 | handleDropdownSelect(e.target.value)}>
56 |
59 | {dropdownAssets.length > 0 &&
60 | dropdownAssets.map(({ asset_id, template_mint, salePrice }) => (
61 |
64 | ))}
65 |
66 |
73 |
74 | );
75 | };
76 |
77 | export default AssetFormSell;
78 |
--------------------------------------------------------------------------------
/components/AssetFormSellPopupMenu/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import {
3 | MenuContainer,
4 | PopupMenuButton,
5 | Menu,
6 | MenuItem,
7 | } from './AssetFormSellPopupMenu.styled';
8 | import { GradientBackground } from '../NavBar/NavBar.styled';
9 | import { ReactComponent as Ellipsis } from '../../public/ellipsis.svg';
10 | import {
11 | useModalContext,
12 | MODAL_TYPES,
13 | CreateMultipleSalesModalProps,
14 | CancelMultipleSalesModalProps,
15 | } from '../Provider';
16 | import { useScrollLock, useEscapeKeyClose } from '../../hooks';
17 |
18 | const AssetFormSellPopupMenu = (): JSX.Element => {
19 | const { openModal, modalProps } = useModalContext();
20 | const [isOpen, setIsOpen] = useState(false);
21 | const togglePopupMenu = () => setIsOpen(!isOpen);
22 | const closePopupMenu = () => setIsOpen(false);
23 | const { saleIds } = modalProps as CancelMultipleSalesModalProps;
24 | const { assetIds } = modalProps as CreateMultipleSalesModalProps;
25 | useScrollLock(isOpen);
26 | useEscapeKeyClose(closePopupMenu);
27 |
28 | const popupMenuItems = [
29 | {
30 | isHidden: !assetIds || assetIds.length === 0,
31 | name: 'Mark all for sale',
32 | onClick: () => {
33 | setIsOpen(false);
34 | openModal(MODAL_TYPES.CREATE_MULTIPLE_SALES);
35 | },
36 | },
37 | {
38 | isHidden: !saleIds || saleIds.length === 0,
39 | name: 'Cancel all sales',
40 | onClick: () => {
41 | setIsOpen(false);
42 | openModal(MODAL_TYPES.CANCEL_MULTIPLE_SALES);
43 | },
44 | },
45 | ];
46 |
47 | return (
48 |
49 |
50 |
51 |
52 |
63 |
68 |
69 | );
70 | };
71 |
72 | export default AssetFormSellPopupMenu;
73 |
--------------------------------------------------------------------------------
/components/Provider/ModalProvider.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useState,
3 | createContext,
4 | useContext,
5 | ReactNode,
6 | Dispatch,
7 | SetStateAction,
8 | } from 'react';
9 | import { useScrollLock } from '../../hooks';
10 |
11 | export const MODAL_TYPES = {
12 | HIDDEN: 'HIDDEN',
13 | CLAIM: 'CLAIM',
14 | CREATE_SALE: 'CREATE_SALE',
15 | CREATE_MULTIPLE_SALES: 'CREATE_MULTIPLE_SALES',
16 | CANCEL_SALE: 'CANCEL_SALE',
17 | CANCEL_MULTIPLE_SALES: 'CANCEL_MULTIPLE_SALES',
18 | };
19 |
20 | type Props = {
21 | children: ReactNode;
22 | };
23 |
24 | interface SaleModalProps {
25 | fetchPageData: () => Promise;
26 | }
27 |
28 | export interface CancelSaleModalProps extends SaleModalProps {
29 | saleId: string;
30 | }
31 |
32 | export interface CancelMultipleSalesModalProps extends SaleModalProps {
33 | saleIds: string[];
34 | }
35 |
36 | export interface CreateSaleModalProps extends SaleModalProps {
37 | assetId: string;
38 | }
39 |
40 | export interface CreateMultipleSalesModalProps extends SaleModalProps {
41 | assetIds: string[];
42 | }
43 |
44 | type ModalProps =
45 | | CancelSaleModalProps
46 | | CancelMultipleSalesModalProps
47 | | CreateSaleModalProps
48 | | CreateMultipleSalesModalProps;
49 |
50 | type ModalContextValue = {
51 | modalType: string;
52 | openModal: (type: string) => void;
53 | closeModal: () => void;
54 | modalProps: ModalProps;
55 | setModalProps: Dispatch>;
56 | };
57 |
58 | const ModalContext = createContext({
59 | modalType: MODAL_TYPES.HIDDEN,
60 | openModal: undefined,
61 | closeModal: undefined,
62 | modalProps: undefined,
63 | setModalProps: () => {},
64 | });
65 |
66 | export const useModalContext = (): ModalContextValue => {
67 | const context = useContext(ModalContext);
68 | return context;
69 | };
70 |
71 | export const ModalProvider = ({ children }: Props): JSX.Element => {
72 | const [modalType, setModalType] = useState(MODAL_TYPES.HIDDEN);
73 | const [modalProps, setModalProps] = useState(undefined);
74 | const openModal = (type: string) => setModalType(type);
75 | const closeModal = () => setModalType(MODAL_TYPES.HIDDEN);
76 | useScrollLock(modalType !== MODAL_TYPES.HIDDEN);
77 |
78 | const value: ModalContextValue = {
79 | modalType,
80 | openModal,
81 | closeModal,
82 | modalProps,
83 | setModalProps,
84 | };
85 |
86 | return (
87 | {children}
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/components/DetailsLayout/index.tsx:
--------------------------------------------------------------------------------
1 | import { ReactNode, useState } from 'react';
2 | import Image from 'next/image';
3 | import {
4 | Container,
5 | Row,
6 | Column,
7 | ImageContainer,
8 | Title,
9 | ContentRow,
10 | ArrowContainer,
11 | ToggleContainer,
12 | Divider,
13 | } from './DetailsLayout.styled';
14 | import SalesHistoryTable from '../SalesHistoryTable';
15 | import AssetFormTitle from '../AssetFormTitle';
16 | import { Sale, SaleAsset } from '../../services/sales';
17 | import { Asset } from '../../services/assets';
18 |
19 | type Props = {
20 | children: ReactNode;
21 | image: string;
22 | templateId: string;
23 | templateName: string;
24 | collectionName: string;
25 | collectionAuthor: string;
26 | sales: Sale[];
27 | error?: string;
28 | currentAsset?: Partial & Partial;
29 | };
30 |
31 | const AssetImage = ({ image }: { image: string }): JSX.Element => (
32 |
33 |
40 |
41 | );
42 |
43 | const DetailsLayout = ({
44 | children,
45 | image,
46 | templateName,
47 | collectionName,
48 | collectionAuthor,
49 | sales,
50 | error,
51 | currentAsset,
52 | }: Props): JSX.Element => {
53 | const [salesTableActive, setSalesTableActive] = useState(true);
54 | return (
55 |
56 |
57 |
58 |
59 |
64 |
65 | {children}
66 |
67 |
68 |
69 | Recent Sales History
70 | setSalesTableActive(!salesTableActive)}>
73 |
81 |
82 |
83 |
84 |
89 |
90 |
91 | );
92 | };
93 |
94 | export default DetailsLayout;
95 |
--------------------------------------------------------------------------------
/services/proton-rpc.ts:
--------------------------------------------------------------------------------
1 | import { JsonRpc } from '@proton/js';
2 | import { formatPrice } from '../utils';
3 | import {
4 | TOKEN_SYMBOL,
5 | TOKEN_CONTRACT,
6 | EMPTY_BALANCE,
7 | } from '../utils/constants';
8 |
9 | class ProtonJs {
10 | rpc: JsonRpc;
11 |
12 | constructor() {
13 | this.rpc = null;
14 | }
15 |
16 | init() {
17 | return new Promise((initResolve, reject) => {
18 | this.setRPC(process.env.NEXT_PUBLIC_CHAIN_ENDPOINT)
19 | .then(() => {
20 | return this.rpc.get_info();
21 | })
22 | .then((result) => {
23 | if (result) {
24 | initResolve();
25 | } else {
26 | reject(new Error('UNABLE TO CONNECT'));
27 | }
28 | })
29 | .catch((err) => {
30 | console.warn(err);
31 | reject(err);
32 | });
33 | });
34 | }
35 |
36 | setRPC = (endpoint) => {
37 | return new Promise((resolve) => {
38 | this.rpc = new JsonRpc(endpoint);
39 | resolve();
40 | });
41 | };
42 |
43 | getAccountBalance = async (account): Promise => {
44 | const balance = await this.rpc.get_currency_balance(
45 | TOKEN_CONTRACT,
46 | account,
47 | TOKEN_SYMBOL
48 | );
49 | return formatPrice(balance[0]);
50 | };
51 |
52 | getProfileImage = async ({ account }): Promise => {
53 | const { rows } = await this.rpc.get_table_rows({
54 | scope: 'eosio.proton',
55 | code: 'eosio.proton',
56 | json: true,
57 | table: 'usersinfo',
58 | lower_bound: account,
59 | upper_bound: account,
60 | });
61 |
62 | return !rows.length ? '' : rows[0].avatar;
63 | };
64 |
65 | getAtomicMarketBalance = (chainAccount: string) => {
66 | return new Promise((resolve, _) => {
67 | this.rpc
68 | .get_table_rows({
69 | json: true,
70 | code: 'atomicmarket',
71 | scope: 'atomicmarket',
72 | table: 'balances',
73 | lower_bound: chainAccount,
74 | limit: 1,
75 | reverse: false,
76 | show_payer: false,
77 | })
78 | .then((res) => {
79 | if (!res.rows.length) {
80 | throw new Error('No balances found for Atomic Market.');
81 | }
82 |
83 | const [balance] = res.rows;
84 | if (balance.owner !== chainAccount || !balance.quantities.length) {
85 | throw new Error(
86 | `No Atomic Market balances found for chain account: ${chainAccount}.`
87 | );
88 | }
89 |
90 | const [amount] = balance.quantities;
91 | resolve(amount);
92 | })
93 | .catch((err) => {
94 | console.warn(err);
95 | resolve(EMPTY_BALANCE);
96 | });
97 | });
98 | };
99 | }
100 |
101 | const proton = new ProtonJs();
102 | proton.init();
103 | export default proton;
104 |
--------------------------------------------------------------------------------
/utils/index.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs';
2 | import timezone from 'dayjs/plugin/timezone';
3 | import utc from 'dayjs/plugin/utc'; // dependent on utc plugin
4 | import { QueryParams, SHORTENED_TOKEN_PRECISION } from './constants';
5 | dayjs.extend(utc);
6 | dayjs.extend(timezone);
7 |
8 | export const toQueryString = (queryObject: QueryParams): string => {
9 | const parts = [];
10 | for (const key in queryObject) {
11 | const value = queryObject[key];
12 | if (value && (typeof value === 'string' || typeof value === 'number')) {
13 | parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
14 | }
15 | }
16 | return parts.length ? parts.join('&') : '';
17 | };
18 |
19 | export const capitalize = (word: string): string => {
20 | if (!word) return '';
21 | return word[0].toUpperCase() + word.slice(1);
22 | };
23 |
24 | export const formatNumber = (numberString: string): string =>
25 | numberString.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
26 |
27 | export const asyncForEach = async (
28 | array: unknown[],
29 | callback: (element: unknown, index: number, array: unknown[]) => void
30 | ): Promise => {
31 | for (let index = 0; index < array.length; index++) {
32 | await callback(array[index], index, array);
33 | }
34 | };
35 |
36 | export const parseTimestamp = (timestamp: string): string => {
37 | if (timestamp) {
38 | return `${dayjs(+timestamp)
39 | .tz('America/Los_Angeles')
40 | .format('MMM DD, YYYY, h:mm A')} PST`;
41 | }
42 | return '';
43 | };
44 |
45 | export const addPrecisionDecimal = (
46 | number: string,
47 | precision: number,
48 | noCommas?: boolean
49 | ): string => {
50 | if (number && number.includes('.')) return formatThousands(number);
51 | if (number && number.length > precision) {
52 | const insertDecimalAtIndex = number.length - precision;
53 | const numberString =
54 | number.slice(0, insertDecimalAtIndex) +
55 | '.' +
56 | number.slice(insertDecimalAtIndex);
57 | if (noCommas) {
58 | return numberString;
59 | }
60 | return formatThousands(parseFloat(numberString).toString());
61 | }
62 |
63 | let prependZeros = '';
64 | for (let i = 0; i < precision - number.length; i++) {
65 | prependZeros += '0';
66 | }
67 | const numberString = `0.${prependZeros + number}`;
68 | if (noCommas) {
69 | return numberString;
70 | }
71 | return formatThousands(parseFloat(numberString).toString());
72 | };
73 |
74 | export const formatPrice = (priceString: string): string => {
75 | const [price, currency] = priceString.split(' ');
76 | const amount = formatThousands(
77 | parseFloat(price.replace(/[,]/g, '')).toFixed(SHORTENED_TOKEN_PRECISION)
78 | );
79 | return `${amount} ${currency}`;
80 | };
81 |
82 | const formatThousands = (numberString: string): string => {
83 | const [integers, decimals] = numberString.split('.');
84 | let salePrice = parseFloat(integers.replace(/[,]/g, '')).toLocaleString();
85 | salePrice = decimals ? salePrice + '.' + decimals : salePrice;
86 | return salePrice;
87 | };
88 |
--------------------------------------------------------------------------------
/components/GridCard/index.tsx:
--------------------------------------------------------------------------------
1 | import { KeyboardEvent } from 'react';
2 | import Image from 'next/image';
3 | import { useRouter } from 'next/router';
4 | import { Template } from '../../services/templates';
5 | import {
6 | Container,
7 | ImageContainer,
8 | Text,
9 | SecondaryText,
10 | Price,
11 | Tag,
12 | EmptyPrice,
13 | ShimmerBlock,
14 | } from './GridCard.styled';
15 | import { formatNumber } from '../../utils';
16 |
17 | type Props = {
18 | text: string;
19 | secondaryText: string;
20 | priceText: string;
21 | image: string;
22 | redirectPath: string;
23 | isLoading?: boolean;
24 | assetsForSale?: string;
25 | totalAssets?: string;
26 | isUsersTemplates?: boolean;
27 | };
28 |
29 | interface TemplateCardProps extends Template {
30 | isLoading: boolean;
31 | isUsersTemplates?: boolean;
32 | }
33 |
34 | const Card = ({
35 | text,
36 | secondaryText,
37 | priceText,
38 | image,
39 | redirectPath,
40 | isLoading,
41 | assetsForSale,
42 | totalAssets,
43 | isUsersTemplates,
44 | }: Props): JSX.Element => {
45 | const router = useRouter();
46 |
47 | const showPrice = () => {
48 | if (!priceText) return ;
49 | return {priceText};
50 | };
51 |
52 | const openDetailPage = () => router.push(redirectPath);
53 |
54 | const handleEnterKey = (e: KeyboardEvent) => {
55 | if (e.key === 'Enter') {
56 | openDetailPage();
57 | }
58 | };
59 |
60 | return (
61 |
62 |
63 |
71 | {isUsersTemplates ? (
72 |
73 | {assetsForSale}/{totalAssets} FOR SALE
74 |
75 | ) : null}
76 |
77 | {text}
78 | {secondaryText}
79 | {isLoading ? : showPrice()}
80 |
81 | );
82 | };
83 |
84 | export const TemplateCard = ({
85 | name,
86 | max_supply,
87 | template_id,
88 | immutable_data: { image },
89 | lowestPrice,
90 | isLoading,
91 | assetsForSale,
92 | totalAssets,
93 | isUsersTemplates,
94 | }: TemplateCardProps): JSX.Element => {
95 | const redirectPath = isUsersTemplates
96 | ? `/my-templates/${template_id}`
97 | : `/${template_id}`;
98 | return (
99 |
110 | );
111 | };
112 |
--------------------------------------------------------------------------------
/components/Modal/Modal.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { MaxWidth } from '../../styles/MaxWidth.styled';
3 | import { breakpoint } from '../../styles/Breakpoints';
4 | import { StyledButton, ButtonProps } from '../Button/Button.styled';
5 |
6 | interface HalfButtonProps extends ButtonProps {
7 | color?: string;
8 | hoverColor?: string;
9 | }
10 |
11 | export const Background = styled.div`
12 | z-index: 3;
13 | position: fixed;
14 | top: 0;
15 | left: 0;
16 | width: 100%;
17 | height: 100%;
18 | background-image: linear-gradient(
19 | rgba(14, 16, 60, 0.3),
20 | rgba(14, 16, 60, 0.4),
21 | rgba(14, 16, 60, 0.5),
22 | rgba(14, 16, 60, 0.6),
23 | rgba(14, 16, 60, 0.67)
24 | );
25 | display: flex;
26 | justify-content: center;
27 | align-items: flex-start;
28 | `;
29 |
30 | export const ModalBox = styled(MaxWidth)`
31 | display: flex;
32 | flex-direction: column;
33 | margin-top: 232px;
34 | padding: 24px 24px 12px;
35 | border-radius: 8px;
36 | box-shadow: 0 8px 8px -4px rgba(0, 0, 0, 0.1), 0 0 4px 0 rgba(0, 0, 0, 0.08);
37 | background-color: #ffffff;
38 |
39 | @media (min-width: 600px) {
40 | max-width: 408px;
41 | }
42 |
43 | ${breakpoint.tablet`
44 | margin-top: 200px;
45 | `}
46 | `;
47 |
48 | export const Section = styled.section`
49 | display: flex;
50 | justify-content: space-between;
51 | align-items: flex-start;
52 | `;
53 |
54 | export const CloseIconContainer = styled.div`
55 | cursor: pointer;
56 | `;
57 |
58 | export const Title = styled.h1`
59 | font-family: GilroySemiBold;
60 | font-size: 24px;
61 | line-height: 32px;
62 | color: #0e103c;
63 | margin-bottom: 16px;
64 | `;
65 |
66 | export const Description = styled.p`
67 | font-size: 14px;
68 | line-height: 24px;
69 | color: #7578b5;
70 | margin-bottom: 24px;
71 | `;
72 |
73 | export const InputLabel = styled(Description).attrs({ as: 'label' })`
74 | font-size: 12px;
75 | line-height: 24px;
76 | display: flex;
77 | flex-direction: column;
78 | margin: 0;
79 | `;
80 |
81 | export const LinkDescription = styled(Description)`
82 | margin-bottom: 4px;
83 | text-align: center;
84 | font-size: 12px;
85 | `;
86 |
87 | export const WithdrawInputLabel = styled.p`
88 | display: flex;
89 | justify-content: space-between;
90 | `;
91 |
92 | export const AvailableBalance = styled.span`
93 | font-weight: 600;
94 | color: #8a9ef5;
95 | `;
96 |
97 | export const ErrorMessage = styled(Description).attrs({ as: 'span' })`
98 | font-size: 12px;
99 | color: red;
100 | margin-bottom: 0;
101 | `;
102 |
103 | export const Row = styled.div`
104 | display: flex;
105 | `;
106 |
107 | export const HalfButton = styled(StyledButton)`
108 | flex: 1;
109 |
110 | ${({ color }) =>
111 | color &&
112 | `
113 | background-color: ${color};
114 | `}
115 |
116 | ${({ hoverColor }) =>
117 | hoverColor &&
118 | `
119 | :hover {
120 | background-color: ${hoverColor};
121 | }
122 | `}
123 | `;
124 |
125 | export const Spacer = styled.div`
126 | flex: 1;
127 | `;
128 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # Monsters NFT Demo
3 |
4 | This demo shows the basic functionality of NFTs on the Proton chain. Through the use of the [Proton Web SDK](https://www.npmjs.com/package/@proton/web-sdk), this demo allows for purchasing and
5 | selling of `monsters` NFT. You may view the live version of this demo (which uses Protonchain mainnet) [here](https://nft.protonchain.com).
6 |
7 | This is built off of atomicassets NFT framework.
8 |
9 | - [API Documentation for atomicassets (mainnet)](https://proton.api.atomicassets.io/atomicassets/docs/swagger/)
10 | - [API Documentation for atomicmarket (mainnet)](https://proton.api.atomicassets.io/atomicmarket/docs/swagger/)
11 | - [API Documentation for atomicassets (testnet)](https://test.proton.api.atomicassets.io/atomicassets/docs/swagger/)
12 | - [API Documentation for atomicmarket (testnet)](https://test.proton.api.atomicassets.io/atomicmarket/docs/swagger/)
13 |
14 | The demo uses FOOBAR as a currency to buy and sell the monster NFTs. To get some FOOBAR tokens,
15 | use the [FOOBAR Faucet](https://foobar.protonchain.com).
16 |
17 | ## To build and run locally
18 |
19 | ### Docker
20 |
21 | Run a docker container:
22 |
23 | ```
24 | docker build \
25 | --tag nft-demo \
26 | --build-arg NEXT_PUBLIC_CHAIN_ID="384da888112027f0321850a169f737c33e53b388aad48b5adace4bab97f437e0" \
27 | --build-arg NEXT_PUBLIC_CHAIN_ENDPOINT="https://proton.greymass.com" \
28 | --build-arg NEXT_PUBLIC_BLOCK_EXPLORER="https://proton.bloks.io/block/" \
29 | --build-arg NEXT_PUBLIC_GA_TRACKING_ID="YOUR_TRACKING_ID_HERE" \
30 | --build-arg NEXT_PUBLIC_NFT_ENDPOINT="https://proton.api.atomicassets.io" \
31 | .
32 |
33 | docker images
34 |
35 | docker run -p 3000:3000 -i -d [image id]
36 | ```
37 |
38 | ### npm
39 |
40 | ```
41 | git clone https://github.com/ProtonProtocol/nft-demo.git
42 |
43 | npm install
44 |
45 | npm run dev
46 | ```
47 |
48 | ## Environment
49 |
50 | Create a copy of `.env.template` and name it `.env.local`:
51 |
52 | For mainnet:
53 | ```
54 | NEXT_PUBLIC_CHAIN_ID='384da888112027f0321850a169f737c33e53b388aad48b5adace4bab97f437e0'
55 | NEXT_PUBLIC_CHAIN_ENDPOINT='https://proton.greymass.com'
56 | NEXT_PUBLIC_BLOCK_EXPLORER='https://proton.bloks.io/block/'
57 | NEXT_PUBLIC_NFT_ENDPOINT='https://proton.api.atomicassets.io'
58 | ```
59 |
60 | For testnet:
61 | ```
62 | NEXT_PUBLIC_CHAIN_ID='71ee83bcf52142d61019d95f9cc5427ba6a0d7ff8accd9e2088ae2abeaf3d3dd'
63 | NEXT_PUBLIC_CHAIN_ENDPOINT='https://testnet.protonchain.com'
64 | NEXT_PUBLIC_BLOCK_EXPLORER='https://proton-test.bloks.io/block/'
65 | NEXT_PUBLIC_NFT_ENDPOINT='https://test.proton.api.atomicassets.io'
66 | ```
67 |
68 | ## Marketplace
69 |
70 | The marketplace page consists of templates of a specific `collection_name`.
71 |
72 | ### Custom flags
73 |
74 | - The `Template` object is extended with the following custom property: `lowestPrice`.
75 | - `lowestPrice` (string) is determined by checking the Sales API for assets listed for sale and finding the lowest price of the assets of that particular template.
76 |
77 | ## My NFTs
78 |
79 | The `My NFTs` page consists of the current user's assets. Each user is only allowed to view their own collection page in this demo.
80 |
81 | ### Custom flags
82 |
83 | - The `Asset` object is extended with the following custom properties: `isForSale` and `salePrice`.
84 | - `isForSale` (boolean) is determined by checking the Sales API for currently listed sales using the `asset_id` and `seller` (current user's `chainAccount`)
85 | - `salePrice` (string) is determined by checking the Sales API and combining an asset's `listing_price` and `listing_symbol`
86 |
--------------------------------------------------------------------------------
/components/AssetFormBuy/index.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useEffect } from 'react';
2 | import Button from '../Button';
3 | import { useAuthContext } from '../Provider';
4 | import {
5 | ErrorMessage,
6 | DropdownMenu,
7 | General,
8 | Amount,
9 | Row,
10 | } from './AssetFormBuy.styled';
11 | import { SaleAsset } from '../../services/sales';
12 |
13 | type Props = {
14 | dropdownAssets: SaleAsset[];
15 | lowestPrice: string;
16 | maxSupply: string;
17 | buttonText: string;
18 | saleId: string;
19 | purchasingError: string;
20 | formattedPricesBySaleId: {
21 | [templateMint: string]: string;
22 | };
23 | handleButtonClick: () => void;
24 | setPurchasingError: Dispatch>;
25 | setIsBalanceInsufficient: Dispatch>;
26 | setSaleId: Dispatch>;
27 | setCurrentAsset: Dispatch>;
28 | };
29 |
30 | export const AssetFormBuy = ({
31 | dropdownAssets,
32 | lowestPrice,
33 | maxSupply,
34 | buttonText,
35 | saleId,
36 | purchasingError,
37 | formattedPricesBySaleId,
38 | handleButtonClick,
39 | setPurchasingError,
40 | setIsBalanceInsufficient,
41 | setSaleId,
42 | setCurrentAsset,
43 | }: Props): JSX.Element => {
44 | const { currentUser, currentUserBalance } = useAuthContext();
45 |
46 | useEffect(() => {
47 | dropdownAssets.forEach((asset) => {
48 | if (asset.salePrice === lowestPrice) {
49 | handleDropdownSelect(asset.saleId);
50 | }
51 | });
52 | }, [dropdownAssets, lowestPrice]);
53 |
54 | const balanceAmount = parseFloat(
55 | currentUserBalance.split(' ')[0].replace(/[,]/g, '')
56 | );
57 |
58 | const handleDropdownSelect = (id: string) => {
59 | const priceString = formattedPricesBySaleId[id];
60 | const amount = parseFloat(priceString.split(' ')[0].replace(/[,]/g, ''));
61 | setPurchasingError('');
62 | setIsBalanceInsufficient(false);
63 | setSaleId(id);
64 | setCurrentAsset(dropdownAssets.find((asset) => asset.saleId === id));
65 |
66 | if (!currentUser) {
67 | setPurchasingError('You must log in to purchase an asset.');
68 | }
69 |
70 | if (amount > balanceAmount) {
71 | setIsBalanceInsufficient(true);
72 | setPurchasingError(
73 | `Insufficient funds: this NFT is listed for ${priceString} and your account balance is ${currentUserBalance}. Please visit Foobar Faucet for more funds to continue this transaction.`
74 | );
75 | }
76 | };
77 |
78 | return (
79 |
80 |
81 | Lowest Market Price
82 | Edition Size
83 |
84 |
85 | {lowestPrice || 'None'}
86 | {maxSupply}
87 |
88 | Serial number
89 | handleDropdownSelect(e.target.value)}>
93 |
96 | {dropdownAssets.length > 0 &&
97 | dropdownAssets.map(({ saleId, templateMint, salePrice }) => (
98 |
101 | ))}
102 |
103 |
106 | {purchasingError ? {purchasingError} : null}
107 |
108 | );
109 | };
110 |
111 | export default AssetFormBuy;
112 |
--------------------------------------------------------------------------------
/.github/workflows/google.yml:
--------------------------------------------------------------------------------
1 | # This workflow will build a docker container, publish it to Google Container Registry, and deploy it to GKE when a release is created
2 | #
3 | # To configure this workflow:
4 | #
5 | # 1. Ensure that your repository contains the necessary configuration for your Google Kubernetes Engine cluster, including deployment.yml, kustomization.yml, service.yml, etc.
6 | #
7 | # 2. Set up secrets in your workspace: GKE_PROJECT with the name of the project and GKE_SA_KEY with the Base64 encoded JSON service account key (https://github.com/GoogleCloudPlatform/github-actions/tree/docs/service-account-key/setup-gcloud#inputs).
8 | #
9 | # 3. Change the values for the GKE_ZONE, GKE_CLUSTER, IMAGE, and DEPLOYMENT_NAME environment variables (below).
10 | #
11 | # For more support on how to run the workflow, please visit https://github.com/GoogleCloudPlatform/github-actions/tree/master/example-workflows/gke
12 |
13 | name: Build and Deploy to GKE
14 |
15 | on:
16 | push:
17 | branches:
18 | - master
19 |
20 | env:
21 | PROJECT_ID: ${{ secrets.GKE_PROJECT }}
22 | GKE_CLUSTER: proton-taskly-demo # TODO: update to cluster name
23 | GKE_ZONE: us-west1-a # TODO: update to cluster zone
24 | DEPLOYMENT_NAME: nft-demo # TODO: update to deployment name
25 | IMAGE: nft-demo
26 |
27 | jobs:
28 | setup-build-publish-deploy:
29 | name: Setup, Build, Publish, and Deploy
30 | runs-on: ubuntu-latest
31 |
32 | steps:
33 | - name: Checkout
34 | uses: actions/checkout@v2
35 |
36 | # Setup gcloud CLI
37 | - uses: google-github-actions/setup-gcloud@v0.2.1
38 | with:
39 | service_account_key: ${{ secrets.GKE_SA_KEY }}
40 | project_id: ${{ secrets.GKE_PROJECT }}
41 |
42 | # Configure Docker to use the gcloud command-line tool as a credential
43 | # helper for authentication
44 | - run: |-
45 | gcloud --quiet auth configure-docker
46 |
47 | # Get the GKE credentials so we can deploy to the cluster
48 | - run: |-
49 | gcloud container clusters get-credentials "$GKE_CLUSTER" --zone "$GKE_ZONE"
50 |
51 | # Build the Docker image
52 | - name: Build
53 | run: |-
54 | docker build \
55 | --tag "gcr.io/$PROJECT_ID/$IMAGE:$GITHUB_SHA" \
56 | --build-arg GITHUB_SHA="$GITHUB_SHA" \
57 | --build-arg GITHUB_REF="$GITHUB_REF" \
58 | --build-arg NEXT_PUBLIC_CHAIN_ID="${{ secrets.NEXT_PUBLIC_CHAIN_ID }}" \
59 | --build-arg NEXT_PUBLIC_CHAIN_ENDPOINT="${{ secrets.NEXT_PUBLIC_CHAIN_ENDPOINT }}" \
60 | --build-arg NEXT_PUBLIC_BLOCK_EXPLORER="https://proton.bloks.io/block/" \
61 | --build-arg NEXT_PUBLIC_GA_TRACKING_ID="${{ secrets.NEXT_PUBLIC_GA_TRACKING_ID }}" \
62 | --build-arg NEXT_PUBLIC_NFT_ENDPOINT="https://proton.api.atomicassets.io" \
63 | --build-arg XAUTH_PROTON_MARKET="${{ secrets.XAUTH_PROTON_MARKET }}" \
64 | --build-arg PINATA_API_KEY="${{ secrets.PINATA_API_KEY }}" \
65 | --build-arg PINATA_SECRET="${{ secrets.PINATA_SECRET }}" \
66 | .
67 |
68 | # Push the Docker image to Google Container Registry
69 | - name: Publish
70 | run: |-
71 | docker push "gcr.io/$PROJECT_ID/$IMAGE:$GITHUB_SHA"
72 |
73 | # Set up kustomize
74 | - name: Set up Kustomize
75 | run: |-
76 | curl -sfLo kustomize https://github.com/kubernetes-sigs/kustomize/releases/download/v3.1.0/kustomize_3.1.0_linux_amd64
77 | chmod u+x ./kustomize
78 |
79 | # Deploy the Docker image to the GKE cluster
80 | - name: Deploy
81 | run: |-
82 | ./kustomize edit set image gcr.io/$PROJECT_ID/$IMAGE:$GITHUB_SHA
83 | ./kustomize build . | kubectl apply -f -
84 | kubectl rollout status deployment/$DEPLOYMENT_NAME
85 | kubectl get services -o wide
--------------------------------------------------------------------------------
/components/Modal/CancelSaleModal.tsx:
--------------------------------------------------------------------------------
1 | import { MouseEvent } from 'react';
2 | import {
3 | useAuthContext,
4 | useModalContext,
5 | CancelSaleModalProps,
6 | CancelMultipleSalesModalProps,
7 | } from '../Provider';
8 | import {
9 | Background,
10 | ModalBox,
11 | Section,
12 | CloseIconContainer,
13 | Title,
14 | Description,
15 | Row,
16 | Spacer,
17 | HalfButton,
18 | } from './Modal.styled';
19 | import { ReactComponent as CloseIcon } from '../../public/close.svg';
20 | import ProtonSDK from '../../services/proton';
21 |
22 | type Props = {
23 | title: string;
24 | description: string;
25 | buttonText: string;
26 | onButtonClick: () => Promise;
27 | };
28 |
29 | const CancelModal = ({
30 | title,
31 | description,
32 | buttonText,
33 | onButtonClick,
34 | }: Props): JSX.Element => {
35 | const { closeModal } = useModalContext();
36 |
37 | const handleBackgroundClick = (e: MouseEvent) => {
38 | if (e.target === e.currentTarget) {
39 | closeModal();
40 | }
41 | };
42 |
43 | return (
44 |
45 |
46 |
47 | {title}
48 |
49 |
50 |
51 |
52 | {description}
53 |
54 |
55 |
61 | {buttonText}
62 |
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | export const CancelSaleModal = (): JSX.Element => {
70 | const { currentUser } = useAuthContext();
71 | const { closeModal, modalProps } = useModalContext();
72 | const { saleId, fetchPageData } = modalProps as CancelSaleModalProps;
73 |
74 | const cancelSale = async () => {
75 | const res = await ProtonSDK.cancelSale({
76 | actor: currentUser ? currentUser.actor : '',
77 | sale_id: saleId,
78 | });
79 |
80 | if (res.success) {
81 | closeModal();
82 | fetchPageData();
83 | }
84 | };
85 |
86 | return (
87 |
94 | );
95 | };
96 |
97 | export const CancelMultipleSalesModal = (): JSX.Element => {
98 | const { currentUser } = useAuthContext();
99 | const { closeModal, modalProps } = useModalContext();
100 | const {
101 | saleIds,
102 | fetchPageData,
103 | } = modalProps as CancelMultipleSalesModalProps;
104 |
105 | const onButtonClick = async () => {
106 | try {
107 | const res = await ProtonSDK.cancelMultipleSales({
108 | actor: currentUser ? currentUser.actor : '',
109 | saleIds,
110 | });
111 |
112 | if (res.success) {
113 | closeModal();
114 | fetchPageData();
115 | }
116 | } catch (err) {
117 | throw new Error(err.message);
118 | }
119 | };
120 |
121 | return (
122 |
129 | );
130 | };
131 |
--------------------------------------------------------------------------------
/pages/api/upload.ts:
--------------------------------------------------------------------------------
1 | import { NextApiResponse, NextApiRequest } from 'next';
2 | import Cors from 'cors';
3 | import multer from 'multer';
4 | // import FormData from 'form-data';
5 | // import fetch from 'node-fetch';
6 |
7 | const LG_FILE_SIZE_UPLOAD_LIMIT = 30 * 1000000; // 30 MB
8 |
9 | const cors = Cors({
10 | methods: ['GET', 'POST', 'PUT', 'OPTIONS'],
11 | allowedHeaders: ['Authorization'],
12 | });
13 |
14 | function initMiddleware(middleware) {
15 | return (req: NextApiRequest, res: NextApiResponse) =>
16 | new Promise((resolve, reject) => {
17 | middleware(req, res, (result) => {
18 | if (result instanceof Error) {
19 | return reject(result);
20 | }
21 | return resolve(result);
22 | });
23 | });
24 | }
25 |
26 | function runMiddleware(req, res, fn) {
27 | return new Promise((resolve, reject) => {
28 | fn(req, res, (result) => {
29 | if (result instanceof Error) {
30 | return reject(result);
31 | }
32 |
33 | return resolve(result);
34 | });
35 | });
36 | }
37 |
38 | const upload = multer({
39 | limits: { fileSize: LG_FILE_SIZE_UPLOAD_LIMIT },
40 | });
41 | const multerAny = initMiddleware(upload.any());
42 |
43 | type NextApiRequestWithFormData = NextApiRequest & {
44 | files: FileBuffer[];
45 | };
46 |
47 | type FileBuffer = File & {
48 | buffer?: Buffer;
49 | originalname?: string;
50 | };
51 |
52 | const handler = async (
53 | req: NextApiRequestWithFormData,
54 | res: NextApiResponse
55 | ): Promise => {
56 | await runMiddleware(req, res, cors);
57 |
58 | // const isUnauthorized =
59 | // req.headers['Authorization'] !== `Basic ${process.env.XAUTH_PROTON_MARKET}`;
60 |
61 | // if (isUnauthorized) {
62 | // res.status(401).send({
63 | // success: false,
64 | // message: 'Unauthorized',
65 | // });
66 | // return;
67 | // }
68 |
69 | const { method } = req;
70 | switch (method) {
71 | case 'POST': {
72 | try {
73 | // await multerAny(req, res);
74 | // if (!req.files?.length || req.files.length > 1) {
75 | // res.status(400).send({
76 | // success: false,
77 | // message: 'File not found, please try again.',
78 | // });
79 | // return;
80 | // }
81 | // const blob: FileBuffer = req.files[0];
82 |
83 | // const formData = new FormData();
84 | // formData.append('file', blob.buffer, blob.originalname);
85 |
86 | // const headers = {
87 | // pinata_api_key: process.env.PINATA_API_KEY,
88 | // pinata_secret_api_key: process.env.PINATA_SECRET,
89 | // ...formData.getHeaders(),
90 | // };
91 |
92 | // const resultRaw = await fetch(
93 | // 'https://api.pinata.cloud/pinning/pinFileToIPFS',
94 | // {
95 | // method: 'POST',
96 | // body: formData,
97 | // headers,
98 | // }
99 | // );
100 |
101 | // const result = await resultRaw.json();
102 |
103 | // if (result.error) throw new Error(result.error.message);
104 | res.status(200).send({ success: true, message: 'hey!' });
105 | } catch (e) {
106 | res.status(500).send({
107 | success: false,
108 | message: e.message || 'Error uploading file',
109 | });
110 | }
111 | break;
112 | }
113 | case 'PUT':
114 | break;
115 | case 'PATCH':
116 | break;
117 | default:
118 | res.status(200).send({
119 | success: true,
120 | message: 'Hello!',
121 | });
122 | break;
123 | }
124 | };
125 |
126 | export default handler;
127 |
128 | export const config = {
129 | api: {
130 | bodyParser: false,
131 | },
132 | };
133 |
--------------------------------------------------------------------------------
/components/Provider/AuthProvider.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useState, useContext, useEffect, useMemo } from 'react';
2 | import ProtonSDK, { User } from '../../services/proton';
3 | import proton from '../../services/proton-rpc';
4 | import { usePrevious } from '../../hooks';
5 |
6 | interface AuthContext {
7 | currentUser: User;
8 | currentUserBalance: string;
9 | atomicMarketBalance: string;
10 | authError: string;
11 | login: () => Promise;
12 | logout: () => Promise;
13 | updateCurrentUserBalance: (chainAccount: string) => Promise;
14 | updateAtomicBalance: (chainAccount: string) => Promise;
15 | }
16 |
17 | interface Props {
18 | children: JSX.Element | JSX.Element[];
19 | }
20 |
21 | const AuthContext = createContext({
22 | currentUser: undefined,
23 | currentUserBalance: '',
24 | atomicMarketBalance: '',
25 | authError: '',
26 | login: () => Promise.resolve(),
27 | logout: () => Promise.resolve(),
28 | updateCurrentUserBalance: () => Promise.resolve(),
29 | updateAtomicBalance: () => Promise.resolve(),
30 | });
31 |
32 | export const useAuthContext = (): AuthContext => {
33 | const context = useContext(AuthContext);
34 | return context;
35 | };
36 |
37 | export const AuthProvider = ({ children }: Props): JSX.Element => {
38 | const [currentUser, setCurrentUser] = useState(undefined);
39 | const [currentUserBalance, setCurrentUserBalance] = useState('');
40 | const [atomicMarketBalance, setAtomicMarketBalance] = useState('');
41 | const [authError, setAuthError] = useState('');
42 | const prevError = usePrevious(authError);
43 |
44 | useEffect(() => {
45 | if (prevError) {
46 | setAuthError('');
47 | }
48 | }, [prevError]);
49 |
50 | useEffect(() => {
51 | if (typeof window !== 'undefined' && !currentUser) {
52 | const cachedUser = localStorage.getItem('proton-storage-user-auth');
53 |
54 | if (cachedUser) {
55 | const { actor, permission } = JSON.parse(cachedUser);
56 | setCurrentUser({
57 | actor,
58 | permission,
59 | name: '',
60 | avatar: '/default-avatar.png',
61 | isLightKYCVerified: false,
62 | });
63 | }
64 |
65 | const restore = async () => {
66 | const { user, error } = await ProtonSDK.restoreSession();
67 |
68 | if (error || !user) {
69 | const errorMessage = error
70 | ? `Error: ${error}`
71 | : 'Error: No user was found';
72 | setAuthError(errorMessage);
73 | return;
74 | }
75 |
76 | await updateCurrentUserBalance(user.actor);
77 | await updateAtomicBalance(user.actor);
78 | setCurrentUser(user);
79 | };
80 |
81 | restore();
82 | }
83 | }, []);
84 |
85 | const updateCurrentUserBalance = async (chainAccount: string) => {
86 | const balance = await proton.getAccountBalance(chainAccount);
87 | setCurrentUserBalance(balance);
88 | };
89 |
90 | const updateAtomicBalance = async (chainAccount: string) => {
91 | const balance = await proton.getAtomicMarketBalance(chainAccount);
92 | setAtomicMarketBalance(balance);
93 | };
94 |
95 | const login = async (): Promise => {
96 | const { user, error } = await ProtonSDK.login();
97 | if (error || !user) {
98 | const errorMessage = error
99 | ? `Error: ${error}`
100 | : 'Error: No user was found';
101 | setAuthError(errorMessage);
102 | return;
103 | }
104 |
105 | await updateCurrentUserBalance(user.actor);
106 | await updateAtomicBalance(user.actor);
107 | setCurrentUser(user);
108 | };
109 |
110 | const logout = async () => {
111 | await ProtonSDK.logout();
112 | setCurrentUser(undefined);
113 | };
114 |
115 | const value = useMemo(
116 | () => ({
117 | currentUser,
118 | currentUserBalance,
119 | atomicMarketBalance,
120 | authError,
121 | login,
122 | logout,
123 | updateCurrentUserBalance,
124 | updateAtomicBalance,
125 | }),
126 | [currentUser, authError, currentUserBalance, atomicMarketBalance]
127 | );
128 |
129 | return {children};
130 | };
131 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useRouter } from 'next/router';
3 | import PageLayout from '../components/PageLayout';
4 | import Grid from '../components/Grid';
5 | import PaginationButton from '../components/PaginationButton';
6 | import ErrorComponent from '../components/Error';
7 | import LoadingPage from '../components/LoadingPage';
8 | import { useAuthContext } from '../components/Provider';
9 | import { Title } from '../styles/Title.styled';
10 | import {
11 | Template,
12 | getTemplatesByCollection,
13 | formatTemplatesWithPriceData,
14 | getLowestPricesForAllCollectionTemplates,
15 | } from '../services/templates';
16 | import { DEFAULT_COLLECTION, PAGINATION_LIMIT } from '../utils/constants';
17 |
18 | const MarketPlace = (): JSX.Element => {
19 | const router = useRouter();
20 | const { currentUser } = useAuthContext();
21 | const [isLoading, setIsLoading] = useState(true);
22 | const [lowestPrices, setLowestPrices] = useState<{ [id: string]: string }>(
23 | {}
24 | );
25 | const [renderedTemplates, setRenderedTemplates] = useState([]);
26 | const [prefetchedTemplates, setPrefetchedTemplates] = useState(
27 | []
28 | );
29 | const [prefetchPageNumber, setPrefetchPageNumber] = useState(2);
30 | const [isLoadingNextPage, setIsLoadingNextPage] = useState(true);
31 | const [errorMessage, setErrorMessage] = useState('');
32 | const [collectionType, setCollectionType] = useState(
33 | DEFAULT_COLLECTION
34 | );
35 |
36 | const prefetchNextPage = async () => {
37 | const prefetchedResult = await getTemplatesByCollection({
38 | type: collectionType,
39 | page: prefetchPageNumber,
40 | });
41 | setPrefetchedTemplates(prefetchedResult as Template[]);
42 |
43 | if (!prefetchedResult.length) {
44 | setPrefetchPageNumber(-1);
45 | } else {
46 | setPrefetchPageNumber(prefetchPageNumber + 1);
47 | }
48 |
49 | setIsLoadingNextPage(false);
50 | };
51 |
52 | const showNextPage = async () => {
53 | const allFetchedTemplates = formatTemplatesWithPriceData(
54 | renderedTemplates.concat(prefetchedTemplates),
55 | lowestPrices
56 | );
57 | setRenderedTemplates(allFetchedTemplates);
58 | setIsLoadingNextPage(true);
59 | await prefetchNextPage();
60 | };
61 |
62 | useEffect(() => {
63 | (async () => {
64 | try {
65 | const lowestPricesResult = await getLowestPricesForAllCollectionTemplates(
66 | { type: collectionType }
67 | );
68 | setLowestPrices(lowestPricesResult);
69 |
70 | const result = await getTemplatesByCollection({ type: collectionType });
71 | const templatesWithLowestPrice = formatTemplatesWithPriceData(
72 | result,
73 | lowestPricesResult
74 | );
75 | setRenderedTemplates(templatesWithLowestPrice);
76 |
77 | setIsLoading(false);
78 | await prefetchNextPage();
79 | } catch (e) {
80 | setErrorMessage(e.message);
81 | }
82 | })();
83 | }, []);
84 |
85 | useEffect(() => {
86 | if (currentUser) {
87 | router.prefetch(`/collection/${currentUser.actor}`);
88 | }
89 | }, []);
90 |
91 | const getContent = () => {
92 | if (isLoading) {
93 | return ;
94 | }
95 |
96 | if (!renderedTemplates.length) {
97 | return (
98 |
99 | );
100 | }
101 |
102 | if (errorMessage) {
103 | return (
104 | router.reload()}
108 | />
109 | );
110 | }
111 |
112 | return (
113 | <>
114 |
115 |
121 | >
122 | );
123 | };
124 |
125 | return (
126 |
127 | MarketPlace
128 | {getContent()}
129 |
130 | );
131 | };
132 |
133 | export default MarketPlace;
134 |
--------------------------------------------------------------------------------
/components/Modal/CreateSaleModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState, MouseEvent, Dispatch, SetStateAction } from 'react';
2 | import {
3 | useAuthContext,
4 | useModalContext,
5 | CreateSaleModalProps,
6 | CreateMultipleSalesModalProps,
7 | } from '../Provider';
8 | import PriceInput from '../PriceInput';
9 | import {
10 | Background,
11 | ModalBox,
12 | Section,
13 | CloseIconContainer,
14 | Title,
15 | Description,
16 | InputLabel,
17 | Row,
18 | Spacer,
19 | HalfButton,
20 | } from './Modal.styled';
21 | import { ReactComponent as CloseIcon } from '../../public/close.svg';
22 | import { TOKEN_SYMBOL, TOKEN_PRECISION } from '../../utils/constants';
23 | import ProtonSDK from '../../services/proton';
24 |
25 | type Props = {
26 | title: string;
27 | description: string;
28 | buttonText: string;
29 | amount: string;
30 | onButtonClick: () => Promise;
31 | setAmount: Dispatch>;
32 | };
33 |
34 | const SaleModal = ({
35 | title,
36 | description,
37 | buttonText,
38 | amount,
39 | setAmount,
40 | onButtonClick,
41 | }: Props): JSX.Element => {
42 | const { closeModal } = useModalContext();
43 |
44 | const handleBackgroundClick = (e: MouseEvent) => {
45 | if (e.target === e.currentTarget) {
46 | closeModal();
47 | }
48 | };
49 |
50 | return (
51 |
52 |
53 |
54 | {title}
55 |
56 |
57 |
58 |
59 | {description}
60 |
61 | NFT Price
62 |
68 |
69 |
70 |
71 |
72 | {buttonText}
73 |
74 |
75 |
76 |
77 | );
78 | };
79 |
80 | export const CreateSaleModal = (): JSX.Element => {
81 | const { currentUser } = useAuthContext();
82 | const { closeModal, modalProps } = useModalContext();
83 | const { assetId, fetchPageData } = modalProps as CreateSaleModalProps;
84 | const [amount, setAmount] = useState('');
85 |
86 | const createOneSale = async () => {
87 | const formattedAmount = parseFloat(amount).toFixed(TOKEN_PRECISION);
88 | const res = await ProtonSDK.createSale({
89 | seller: currentUser ? currentUser.actor : '',
90 | asset_id: assetId,
91 | price: `${formattedAmount} ${TOKEN_SYMBOL}`,
92 | currency: `${TOKEN_PRECISION},${TOKEN_SYMBOL}`,
93 | });
94 |
95 | if (res.success) {
96 | closeModal();
97 | fetchPageData();
98 | }
99 | };
100 |
101 | return (
102 |
110 | );
111 | };
112 |
113 | export const CreateMultipleSalesModal = (): JSX.Element => {
114 | const { currentUser } = useAuthContext();
115 | const { closeModal, modalProps } = useModalContext();
116 | const {
117 | assetIds,
118 | fetchPageData,
119 | } = modalProps as CreateMultipleSalesModalProps;
120 | const [amount, setAmount] = useState('');
121 |
122 | const createMultipleSales = async () => {
123 | try {
124 | const formattedAmount = parseFloat(amount).toFixed(TOKEN_PRECISION);
125 | const res = await ProtonSDK.createMultipleSales({
126 | seller: currentUser ? currentUser.actor : '',
127 | assetIds,
128 | price: `${formattedAmount} ${TOKEN_SYMBOL}`,
129 | currency: `${TOKEN_PRECISION},${TOKEN_SYMBOL}`,
130 | });
131 |
132 | if (res.success) {
133 | closeModal();
134 | fetchPageData();
135 | }
136 | } catch (err) {
137 | throw new Error(err.message);
138 | }
139 | };
140 |
141 | return (
142 |
150 | );
151 | };
152 |
--------------------------------------------------------------------------------
/pages/my-nfts/[chainAccount].tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 | import { useRouter } from 'next/router';
3 | import PageLayout from '../../components/PageLayout';
4 | import PaginationButton from '../../components/PaginationButton';
5 | import ErrorComponent from '../../components/Error';
6 | import Grid from '../../components/Grid';
7 | import { useAuthContext } from '../../components/Provider';
8 | import { getTemplatesWithUserAssetCount } from '../../services/templates';
9 | import { Template } from '../../services/templates';
10 | import { Title } from '../../styles/Title.styled';
11 | import LoadingPage from '../../components/LoadingPage';
12 | import { capitalize } from '../../utils';
13 | import { PAGINATION_LIMIT } from '../../utils/constants';
14 |
15 | type RouterQuery = {
16 | chainAccount: string;
17 | };
18 |
19 | type GetMyTemplatesOptions = {
20 | chainAccount: string;
21 | page?: number;
22 | };
23 |
24 | const getMyTemplates = async ({
25 | chainAccount,
26 | page,
27 | }: GetMyTemplatesOptions): Promise => {
28 | try {
29 | const pageParam = page ? page : 1;
30 | const result = await getTemplatesWithUserAssetCount(
31 | chainAccount,
32 | pageParam
33 | );
34 | return result;
35 | } catch (e) {
36 | throw new Error(e);
37 | }
38 | };
39 |
40 | const Collection = (): JSX.Element => {
41 | const router = useRouter();
42 | const { chainAccount } = router.query as RouterQuery;
43 | const { currentUser } = useAuthContext();
44 | const [renderedTemplates, setRenderedTemplates] = useState([]);
45 | const [prefetchedTemplates, setPrefetchedTemplates] = useState(
46 | []
47 | );
48 | const [prefetchPageNumber, setPrefetchPageNumber] = useState(2);
49 | const [isLoading, setIsLoading] = useState(true);
50 | const [isLoadingNextPage, setIsLoadingNextPage] = useState(true);
51 | const [errorMessage, setErrorMessage] = useState('');
52 | const [currentProfile, setCurrentProfile] = useState('');
53 |
54 | const prefetchNextPage = async () => {
55 | const prefetchedResult = await getMyTemplates({
56 | chainAccount,
57 | page: prefetchPageNumber,
58 | });
59 | setPrefetchedTemplates(prefetchedResult);
60 |
61 | if (!prefetchedResult.length) {
62 | setPrefetchPageNumber(-1);
63 | } else {
64 | setPrefetchPageNumber(prefetchPageNumber + 1);
65 | }
66 |
67 | setIsLoadingNextPage(false);
68 | };
69 |
70 | const showNextPage = async () => {
71 | const allFetchedTemplates = renderedTemplates.concat(prefetchedTemplates);
72 | setRenderedTemplates(allFetchedTemplates);
73 | setIsLoadingNextPage(true);
74 | await prefetchNextPage();
75 | };
76 |
77 | useEffect(() => {
78 | (async () => {
79 | try {
80 | router.prefetch('/');
81 | const templates = await getMyTemplates({ chainAccount });
82 | setRenderedTemplates(templates);
83 | setIsLoading(false);
84 | await prefetchNextPage();
85 | } catch (e) {
86 | setErrorMessage(e.message);
87 | }
88 | })();
89 | }, [chainAccount]);
90 |
91 | useEffect(() => {
92 | if (!currentUser || chainAccount !== currentUser.actor) {
93 | setCurrentProfile(capitalize(chainAccount));
94 | } else {
95 | setCurrentProfile('');
96 | }
97 | }, [currentUser, chainAccount]);
98 |
99 | const getContent = () => {
100 | if (isLoading) {
101 | return ;
102 | }
103 |
104 | if (!renderedTemplates.length) {
105 | return (
106 |
111 | );
112 | }
113 |
114 | if (errorMessage) {
115 | return (
116 | router.reload()}
120 | />
121 | );
122 | }
123 |
124 | return (
125 | <>
126 |
130 |
136 | >
137 | );
138 | };
139 |
140 | return (
141 | <>
142 |
143 | {currentProfile ? `${currentProfile}'s` : 'My'} NFTs
144 | {getContent()}
145 |
146 | >
147 | );
148 | };
149 |
150 | export default Collection;
151 |
--------------------------------------------------------------------------------
/pages/my-templates/[templateId].tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/router';
2 | import { useState, useEffect } from 'react';
3 | import DetailsLayout from '../../components/DetailsLayout';
4 | import ErrorComponent from '../../components/Error';
5 | import PageLayout from '../../components/PageLayout';
6 | import AssetFormSell from '../../components/AssetFormSell';
7 | import LoadingPage from '../../components/LoadingPage';
8 | import {
9 | useAuthContext,
10 | useModalContext,
11 | MODAL_TYPES,
12 | } from '../../components/Provider';
13 | import { getTemplateDetails, Template } from '../../services/templates';
14 | import {
15 | getUserTemplateAssets,
16 | Asset,
17 | FullSaleDataByAssetId,
18 | } from '../../services/assets';
19 | import { getSalesHistoryForAsset, Sale } from '../../services/sales';
20 | import { DEFAULT_COLLECTION } from '../../utils/constants';
21 |
22 | const emptyTemplateDetails = {
23 | lowestPrice: '',
24 | max_supply: '',
25 | collection: {
26 | author: '',
27 | collection_name: '',
28 | },
29 | immutable_data: {
30 | image: '',
31 | name: '',
32 | series: 0,
33 | },
34 | };
35 |
36 | type Query = {
37 | [query: string]: string;
38 | };
39 |
40 | const MyNFTsTemplateDetail = (): JSX.Element => {
41 | const router = useRouter();
42 | const { templateId } = router.query as Query;
43 | const { currentUser } = useAuthContext();
44 | const { openModal, setModalProps } = useModalContext();
45 | const [sales, setSales] = useState([]);
46 | const [templateAssets, setTemplateAssets] = useState([]);
47 | const [
48 | saleDataByAssetId,
49 | setSaleDataByAssetId,
50 | ] = useState({});
51 | const [template, setTemplate] = useState(emptyTemplateDetails);
52 | const [isLoading, setIsLoading] = useState(true);
53 | const [error, setError] = useState('');
54 | const [currentAsset, setCurrentAsset] = useState>({});
55 |
56 | const isSelectedAssetBeingSold =
57 | saleDataByAssetId[currentAsset.asset_id] &&
58 | saleDataByAssetId[currentAsset.asset_id].rawPrice;
59 | const {
60 | lowestPrice,
61 | max_supply,
62 | collection: { author, collection_name },
63 | immutable_data: { image, name },
64 | } = template;
65 |
66 | const fetchPageData = async () => {
67 | try {
68 | const owner = currentUser ? currentUser.actor : '';
69 | setIsLoading(true);
70 |
71 | const templateDetails = await getTemplateDetails(
72 | DEFAULT_COLLECTION,
73 | templateId
74 | );
75 |
76 | const { assets, saleData } = await getUserTemplateAssets(
77 | owner,
78 | templateId
79 | );
80 |
81 | const assetIds = assets
82 | .filter(({ asset_id }) => !saleData[asset_id])
83 | .map(({ asset_id }) => asset_id);
84 |
85 | const saleIds = Object.values(saleData).map(({ saleId }) => saleId);
86 |
87 | setModalProps({
88 | saleIds,
89 | assetIds,
90 | fetchPageData,
91 | });
92 |
93 | setTemplateAssets(assets);
94 | setCurrentAsset(assets[0]);
95 | setSaleDataByAssetId(saleData);
96 | setIsLoading(false);
97 | setTemplate(templateDetails);
98 | setIsLoading(false);
99 | } catch (e) {
100 | setError(e.message);
101 | }
102 | };
103 |
104 | useEffect(() => {
105 | try {
106 | (async () => {
107 | const sales = await getSalesHistoryForAsset(currentAsset.asset_id);
108 | setSales(sales);
109 | })();
110 | } catch (e) {
111 | setError(e.message);
112 | }
113 | }, [currentAsset]);
114 |
115 | useEffect(() => {
116 | if (templateId) {
117 | fetchPageData();
118 | }
119 | }, [templateId]);
120 |
121 | const createSale = () => {
122 | openModal(MODAL_TYPES.CREATE_SALE);
123 | setModalProps({
124 | assetId: currentAsset.asset_id,
125 | fetchPageData,
126 | });
127 | };
128 |
129 | const cancelSale = () => {
130 | openModal(MODAL_TYPES.CANCEL_SALE);
131 | setModalProps({
132 | saleId: saleDataByAssetId[currentAsset.asset_id].saleId,
133 | fetchPageData,
134 | });
135 | };
136 |
137 | const handleButtonClick = isSelectedAssetBeingSold ? cancelSale : createSale;
138 |
139 | const buttonText = isSelectedAssetBeingSold ? 'Cancel Sale' : 'Mark for sale';
140 |
141 | const getContent = () => {
142 | if (error) {
143 | return (
144 | router.reload()}
148 | />
149 | );
150 | }
151 |
152 | if (isLoading) {
153 | return ;
154 | }
155 |
156 | return (
157 |
166 |
175 |
176 | );
177 | };
178 |
179 | return {getContent()};
180 | };
181 |
182 | export default MyNFTsTemplateDetail;
183 |
--------------------------------------------------------------------------------
/components/NavBar/NavBar.styled.ts:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 | import { MaxWidth } from '../../styles/MaxWidth.styled';
3 | import { breakpoint } from '../../styles/Breakpoints';
4 | import { FadeInImageContainer } from '../../styles/FadeInImageContainer.styled';
5 |
6 | type DropdownProps = {
7 | isOpen: boolean;
8 | };
9 |
10 | type GradientBackgroundProps = {
11 | isOpen: boolean;
12 | isTransparent?: boolean;
13 | };
14 |
15 | type DropdownLinkProps = {
16 | red?: boolean;
17 | };
18 |
19 | type NavLinkProps = {
20 | isActive: boolean;
21 | };
22 |
23 | export const Background = styled.section`
24 | width: 100%;
25 | background: white;
26 | border-bottom: 1px solid #e8ecfd;
27 | z-index: 2;
28 | position: fixed;
29 | top: 0;
30 | `;
31 |
32 | export const Nav = styled(MaxWidth).attrs({ as: 'nav' })`
33 | justify-content: space-between;
34 | align-items: center;
35 | width: 100%;
36 | position: relative;
37 | `;
38 |
39 | export const Section = styled.section`
40 | display: flex;
41 | justify-content: space-between;
42 | align-items: center;
43 | `;
44 |
45 | export const UserMenuButton = styled.button`
46 | border-radius: 30px;
47 | display: flex;
48 | justify-content: space-between;
49 | align-items: center;
50 | cursor: pointer;
51 | border: 1px solid #dde4ee;
52 | background: none;
53 | padding: 0;
54 | `;
55 |
56 | export const UserMenuText = styled.span`
57 | color: #0e103c;
58 | font-size: 14px;
59 | margin: 8px 8px 8px 16px;
60 | `;
61 |
62 | export const AvatarContainer = styled(FadeInImageContainer)`
63 | width: 40px;
64 | height: 40px;
65 | position: relative;
66 |
67 | * {
68 | border-radius: 100%;
69 | z-index: 3;
70 | }
71 |
72 | ${breakpoint.tablet`
73 | margin: 0;
74 | width: 32px;
75 | height: 32px;
76 | `}
77 | `;
78 |
79 | export const ImageLink = styled.a`
80 | margin: 16px 0;
81 | z-index: 3;
82 | `;
83 |
84 | export const DropdownList = styled.section`
85 | display: ${({ isOpen }) => (isOpen ? 'flex' : 'none')};
86 | flex-direction: column;
87 | position: absolute;
88 | top: 60px;
89 | right: 0;
90 | background: #ffffff;
91 | border-radius: 8px;
92 | box-shadow: 0 12px 20px -4px rgba(0, 0, 0, 0.1), 0 0 8px 0 rgba(0, 0, 0, 0.08);
93 | min-width: 224px;
94 | z-index: 2;
95 |
96 | ${breakpoint.tablet`
97 | display: ${({ isOpen }) => (isOpen ? 'flex' : 'none')};
98 | flex-direction: column;
99 | position: absolute;
100 | right: 0;
101 | z-index: 2;
102 | top: 65px;
103 | width: 100%;
104 |
105 | &:before {
106 | content: '';
107 | background: #ffffff;
108 | width: 100%;
109 | height: 350px;
110 | position: fixed;
111 | top: 0;
112 | left: 0;
113 | z-index: -1;
114 | }
115 | `}
116 | `;
117 |
118 | export const GradientBackground = styled.div`
119 | display: ${({ isOpen }) => (isOpen ? 'block' : 'none')};
120 | z-index: 1;
121 | width: 100%;
122 | height: 100%;
123 | position: fixed;
124 | top: 0;
125 | left: 0;
126 | cursor: pointer;
127 |
128 | ${({ isTransparent }) =>
129 | !isTransparent &&
130 | `
131 | ${breakpoint.tablet`
132 | background-image: linear-gradient(
133 | rgba(14, 16, 60, 0.3),
134 | rgba(14, 16, 60, 0.4),
135 | rgba(14, 16, 60, 0.5),
136 | rgba(14, 16, 60, 0.6),
137 | rgba(14, 16, 60, 0.67)
138 | );
139 | `}
140 | `}
141 | `;
142 |
143 | export const Name = styled.span`
144 | font-family: GilroySemiBold;
145 | color: #0e103c;
146 | font-weight: 600;
147 | font-size: 16px;
148 | line-height: 24px;
149 | margin: 0 16px;
150 | padding: 16px 0 8px;
151 |
152 | ${breakpoint.tablet`
153 | border-top: 1px solid #dde4ee;
154 | margin: 0;
155 | `}
156 | `;
157 |
158 | export const Subtitle = styled.span`
159 | color: #7578b5;
160 | font-weight: 500;
161 | font-size: 14px;
162 | line-height: 24px;
163 | margin: 0 16px;
164 |
165 | ${breakpoint.tablet`
166 | margin: 0;
167 | `}
168 | `;
169 |
170 | export const Balance = styled(Name)`
171 | font-size: 18px;
172 | border-top: 0;
173 | border-bottom: 1px solid #dde4ee;
174 | margin: 0 16px;
175 | padding: 0 0 16px;
176 |
177 | ${breakpoint.tablet`
178 | margin: 0;
179 | `}
180 | `;
181 |
182 | export const DropdownLink = styled.a`
183 | font-weight: 500;
184 | color: ${({ red }) => (red ? '#fb849a' : '#0e103c')};
185 | font-size: 16px;
186 | line-height: 24px;
187 | cursor: pointer;
188 | padding: 16px 16px 0;
189 | width: 100%;
190 | transition: 0.2s;
191 |
192 | :last-of-type {
193 | padding-bottom: 16px;
194 | }
195 |
196 | :hover {
197 | color: ${({ red }) => (red ? '#ff002e' : '#7578b5')};
198 | }
199 |
200 | ${breakpoint.tablet`
201 | padding: 16px 0;
202 | border-bottom: 1px solid #dde4ee;
203 |
204 | :last-of-type {
205 | border: none;
206 | }
207 | `}
208 | `;
209 |
210 | export const DesktopOnlySection = styled.section`
211 | ${breakpoint.tablet`
212 | display: none;
213 | `}
214 | `;
215 |
216 | export const DesktopNavLink = styled.a`
217 | color: ${({ isActive }) => (isActive ? '#7578b5' : '#0e103c')};
218 | font-weight: ${({ isActive }) => (isActive ? 600 : 500)};
219 | border-bottom: 2px solid ${({ isActive }) => (isActive ? '#8a9ef5' : 'white')};
220 | cursor: pointer;
221 | margin-right: 40px;
222 | font-size: 16px;
223 | padding: 21px 0;
224 | transition: 0.2s;
225 |
226 | :hover,
227 | :focus-visible {
228 | color: #7578b5;
229 | }
230 | `;
231 |
232 | export const MobileIcon = styled.div`
233 | display: none;
234 | cursor: pointer;
235 |
236 | ${breakpoint.tablet`
237 | display: block;
238 | `}
239 | `;
240 |
241 | export const DesktopIcon = styled.div`
242 | display: flex;
243 | align-items: center;
244 | cursor: pointer;
245 |
246 | ${breakpoint.tablet`
247 | display: none;
248 | `}
249 | `;
250 |
--------------------------------------------------------------------------------
/components/NavBar/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import Image from 'next/image';
3 | import Link from 'next/link';
4 | import { useRouter } from 'next/router';
5 | import Button from '../Button';
6 | import {
7 | Background,
8 | Nav,
9 | Section,
10 | UserMenuButton,
11 | UserMenuText,
12 | AvatarContainer,
13 | ImageLink,
14 | DropdownLink,
15 | GradientBackground,
16 | DropdownList,
17 | MobileIcon,
18 | DesktopIcon,
19 | DesktopNavLink,
20 | DesktopOnlySection,
21 | Name,
22 | Subtitle,
23 | Balance,
24 | } from './NavBar.styled';
25 | import { useScrollLock, useEscapeKeyClose } from '../../hooks';
26 | import { useAuthContext } from '../Provider';
27 |
28 | type DropdownProps = {
29 | isOpen: boolean;
30 | closeNavDropdown: () => void;
31 | };
32 |
33 | const Logo = (): JSX.Element => {
34 | const { currentUser } = useAuthContext();
35 | return (
36 |
37 |
38 |
39 |
47 |
48 |
49 |
57 |
58 |
59 |
60 | );
61 | };
62 |
63 | const UserAvatar = ({ isOpen, avatar, toggleNavDropdown }) => {
64 | const { currentUserBalance } = useAuthContext();
65 |
66 | const currentUserAvatar = (
67 |
68 | {currentUserBalance}
69 |
70 |
71 |
72 |
73 | );
74 |
75 | const mobileNavbarIcon = isOpen ? (
76 |
77 |
78 |
79 | ) : (
80 | currentUserAvatar
81 | );
82 |
83 | return (
84 | <>
85 |
86 | {currentUserAvatar}
87 |
88 |
89 | {mobileNavbarIcon}
90 |
91 | >
92 | );
93 | };
94 |
95 | const Dropdown = ({ isOpen, closeNavDropdown }: DropdownProps): JSX.Element => {
96 | const router = useRouter();
97 | const { currentUser, currentUserBalance, logout } = useAuthContext();
98 | useEscapeKeyClose(closeNavDropdown);
99 |
100 | const routes = [
101 | {
102 | name: 'My NFTs',
103 | path: `/my-nfts/${currentUser ? currentUser.actor : ''}`,
104 | onClick: closeNavDropdown,
105 | },
106 | {
107 | name: 'Marketplace',
108 | path: '/',
109 | onClick: closeNavDropdown,
110 | },
111 | {
112 | name: 'Sign out',
113 | path: '',
114 | onClick: () => {
115 | closeNavDropdown();
116 | logout();
117 | router.push('/');
118 | },
119 | isRed: true,
120 | },
121 | ];
122 |
123 | return (
124 |
125 | {currentUser ? currentUser.name : ''}
126 | Balance
127 | {currentUserBalance ? currentUserBalance : 0}
128 | {routes.map(({ name, path, onClick, isRed }) =>
129 | path ? (
130 |
131 | {name}
132 |
133 | ) : (
134 |
135 | {name}
136 |
137 | )
138 | )}
139 |
140 | );
141 | };
142 |
143 | const DesktopNavRoutes = () => {
144 | const router = useRouter();
145 | const { currentUser } = useAuthContext();
146 |
147 | const routes = [
148 | {
149 | name: 'Marketplace',
150 | path: '/',
151 | isHidden: false,
152 | },
153 | {
154 | name: "My NFT's",
155 | path: `/my-nfts/${currentUser ? currentUser.actor : ''}`,
156 | isHidden: !currentUser,
157 | },
158 | ];
159 |
160 | return (
161 |
162 | {routes.map(({ name, path, isHidden }) => {
163 | const isActive = router.pathname.split('/')[1] === path.split('/')[1];
164 | return isHidden ? null : (
165 |
166 | {name}
167 |
168 | );
169 | })}
170 |
171 | );
172 | };
173 |
174 | const NavBar = (): JSX.Element => {
175 | const { currentUser, login } = useAuthContext();
176 | const [isOpen, setIsOpen] = useState(false);
177 | const [isLoginDisabled, setIsLoginDisabled] = useState(false);
178 | useScrollLock(isOpen);
179 |
180 | const toggleNavDropdown = () => setIsOpen(!isOpen);
181 |
182 | const closeNavDropdown = () => setIsOpen(false);
183 |
184 | const connectWallet = async () => {
185 | setIsLoginDisabled(true);
186 | await login();
187 | closeNavDropdown();
188 | setIsLoginDisabled(false);
189 | };
190 |
191 | return (
192 |
193 |
216 |
217 | );
218 | };
219 |
220 | export default NavBar;
221 |
--------------------------------------------------------------------------------
/pages/[templateId].tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import { useRouter } from 'next/router';
3 | import DetailsLayout from '../components/DetailsLayout';
4 | import ErrorComponent from '../components/Error';
5 | import PageLayout from '../components/PageLayout';
6 | import AssetFormBuy from '../components/AssetFormBuy';
7 | import LoadingPage from '../components/LoadingPage';
8 | import { useAuthContext } from '../components/Provider';
9 | import { getTemplateDetails, Template } from '../services/templates';
10 | import {
11 | getAllTemplateSales,
12 | getSalesHistoryForAsset,
13 | Sale,
14 | SaleAsset,
15 | } from '../services/sales';
16 | import ProtonSDK from '../services/proton';
17 | import { DEFAULT_COLLECTION } from '../utils/constants';
18 | import * as gtag from '../utils/gtag';
19 |
20 | const emptyTemplateDetails = {
21 | lowestPrice: '',
22 | max_supply: '',
23 | collection: {
24 | author: '',
25 | collection_name: '',
26 | },
27 | immutable_data: {
28 | image: '',
29 | name: '',
30 | series: 0,
31 | },
32 | };
33 |
34 | type Query = {
35 | [query: string]: string;
36 | };
37 |
38 | const MarketplaceTemplateDetail = (): JSX.Element => {
39 | const router = useRouter();
40 | const { templateId } = router.query as Query;
41 | const {
42 | updateCurrentUserBalance,
43 | currentUser,
44 | currentUserBalance,
45 | login,
46 | } = useAuthContext();
47 |
48 | const [sales, setSales] = useState([]);
49 | const [templateAssets, setTemplateAssets] = useState([]);
50 | const [formattedPricesBySaleId, setFormattedPricesBySaleId] = useState<{
51 | [templateMint: string]: string;
52 | }>({});
53 | const [rawPricesBySaleId, setRawPricesBySaleId] = useState<{
54 | [templateMint: string]: string;
55 | }>({});
56 | const [purchasingError, setPurchasingError] = useState('');
57 | const [isBalanceInsufficient, setIsBalanceInsufficient] = useState(
58 | false
59 | );
60 | const [template, setTemplate] = useState(emptyTemplateDetails);
61 | const [isLoading, setIsLoading] = useState(true);
62 | const [error, setError] = useState('');
63 | const [saleId, setSaleId] = useState('');
64 | const [currentAsset, setCurrentAsset] = useState>({});
65 |
66 | const balanceAmount = parseFloat(
67 | currentUserBalance.split(' ')[0].replace(/[,]/g, '')
68 | );
69 | const {
70 | lowestPrice,
71 | max_supply,
72 | collection: { author, collection_name },
73 | immutable_data: { image, name },
74 | } = template;
75 |
76 | useEffect(() => {
77 | if (templateId) {
78 | try {
79 | (async () => {
80 | setIsLoading(true);
81 | const templateDetails = await getTemplateDetails(
82 | DEFAULT_COLLECTION,
83 | templateId
84 | );
85 | const {
86 | formattedPrices,
87 | rawPrices,
88 | assets,
89 | } = await getAllTemplateSales(templateId);
90 |
91 | setTemplateAssets(assets);
92 | setFormattedPricesBySaleId(formattedPrices);
93 | setRawPricesBySaleId(rawPrices);
94 | setIsLoading(false);
95 | setTemplate(templateDetails);
96 | setIsLoading(false);
97 | })();
98 | } catch (e) {
99 | setError(e.message);
100 | }
101 | }
102 | }, [templateId]);
103 |
104 | useEffect(() => {
105 | try {
106 | (async () => {
107 | const sales = await getSalesHistoryForAsset(currentAsset.assetId);
108 | setSales(sales);
109 | })();
110 | } catch (e) {
111 | setError(e.message);
112 | }
113 | }, [currentAsset]);
114 |
115 | useEffect(() => {
116 | setPurchasingError('');
117 | if (balanceAmount === 0) setIsBalanceInsufficient(true);
118 | }, [currentUser, currentUserBalance]);
119 |
120 | const buyAsset = async () => {
121 | if (!saleId) {
122 | setPurchasingError('Must select an asset to buy.');
123 | return;
124 | }
125 |
126 | try {
127 | if (!currentUser) {
128 | setPurchasingError('Must be logged in');
129 | return;
130 | }
131 |
132 | const chainAccount = currentUser.actor;
133 | const purchaseResult = await ProtonSDK.purchaseSale({
134 | buyer: chainAccount,
135 | amount: rawPricesBySaleId[saleId],
136 | sale_id: saleId,
137 | });
138 |
139 | if (purchaseResult.success) {
140 | gtag.event({ action: 'buy_nft' });
141 | updateCurrentUserBalance(chainAccount);
142 | setTimeout(() => {
143 | router.push(`/my-nfts/${chainAccount}`);
144 | }, 1000);
145 | } else {
146 | throw purchaseResult.error;
147 | }
148 | } catch (e) {
149 | setPurchasingError(e.message);
150 | }
151 | };
152 |
153 | const handleButtonClick = currentUser
154 | ? isBalanceInsufficient
155 | ? () => window.open('https://foobar.protonchain.com/')
156 | : buyAsset
157 | : login;
158 |
159 | const buttonText = currentUser
160 | ? isBalanceInsufficient
161 | ? 'Visit Foobar Faucet'
162 | : 'Buy'
163 | : 'Connect wallet to buy';
164 |
165 | const getContent = () => {
166 | if (error) {
167 | return (
168 | router.reload()}
172 | />
173 | );
174 | }
175 |
176 | if (isLoading) {
177 | return ;
178 | }
179 |
180 | return (
181 |
190 |
204 |
205 | );
206 | };
207 |
208 | return {getContent()};
209 | };
210 |
211 | export default MarketplaceTemplateDetail;
212 |
--------------------------------------------------------------------------------
/services/assets.ts:
--------------------------------------------------------------------------------
1 | import { Collection, Schema, Template } from './templates';
2 | import { getAssetSale, getAllTemplateSales } from './sales';
3 | import { addPrecisionDecimal, toQueryString } from '../utils';
4 | import { getFromApi } from '../utils/browser-fetch';
5 | import { getUserOffers } from './offers';
6 |
7 | export type Asset = {
8 | name: string;
9 | data: Record;
10 | owner: string;
11 | template: Template;
12 | asset_id: string;
13 | saleId: string;
14 | mutable_data?: Record;
15 | immutable_data?: Record;
16 | template_mint?: string;
17 | schema_mint?: string;
18 | collection_mint?: string;
19 | backed_tokens?: string[] | [];
20 | burned_by_account?: string | null;
21 | burned_at_block?: string | null;
22 | burned_at_time?: string | null;
23 | updated_at_block?: string;
24 | updated_at_time?: string;
25 | transferred_at_block?: string;
26 | transferred_at_time?: string;
27 | minted_at_block?: string;
28 | minted_at_time?: string;
29 | contract?: string;
30 | is_transferable?: boolean;
31 | is_burnable?: boolean;
32 | collection?: Collection;
33 | schema?: Schema;
34 | isForSale?: boolean;
35 | salePrice?: string;
36 | };
37 |
38 | export type RawPrices = {
39 | [assetId: string]: {
40 | rawPrice: string;
41 | saleId: string;
42 | };
43 | };
44 |
45 | interface SaleData {
46 | saleId: string;
47 | templateMint: string;
48 | salePrice: string;
49 | assetId: string;
50 | }
51 |
52 | interface FullSaleData extends SaleData {
53 | rawPrice: string;
54 | }
55 |
56 | type SaleDataByAssetId = {
57 | [assetId: string]: SaleData;
58 | };
59 |
60 | export type FullSaleDataByAssetId = {
61 | [assetId: string]: FullSaleData;
62 | };
63 |
64 | type UserTemplateAssetDetails = {
65 | assets: Asset[];
66 | saleData: FullSaleDataByAssetId;
67 | };
68 |
69 | /**
70 | * Gets a list of all user owned assets of a specific template
71 | * Mostly used in viewing all your owned assets and see which one is listed for sale at a glance.
72 | * @param owner The account name of the owner of the assets to look up
73 | * @param templateId The ID of the template of a group of assets
74 | * @returns {Asset[]} Returns array of Assets owned by the user of a specified template
75 | */
76 |
77 | const getAllUserAssetsByTemplate = async (
78 | owner: string,
79 | templateId: string
80 | ): Promise => {
81 | try {
82 | const limit = 100;
83 | let assets = [];
84 | let hasResults = true;
85 | let page = 1;
86 |
87 | while (hasResults) {
88 | const queryObject = {
89 | owner,
90 | page,
91 | order: 'asc',
92 | sort: 'template_mint',
93 | template_id: templateId,
94 | limit,
95 | };
96 | const queryString = toQueryString(queryObject);
97 | const result = await getFromApi(
98 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/assets?${queryString}`
99 | );
100 |
101 | if (!result.success) {
102 | throw new Error((result.message as unknown) as string);
103 | }
104 |
105 | if (result.data.length < limit) {
106 | hasResults = false;
107 | }
108 |
109 | assets = assets.concat(result.data);
110 | page += 1;
111 | }
112 |
113 | return assets;
114 | } catch (e) {
115 | throw new Error(e);
116 | }
117 | };
118 |
119 | /**
120 | * Gets an index of sale IDs organized by asset ID.
121 | * Mostly used in viewing all your owned assets and see which one is listed for sale at a glance.
122 | * @param owner The account name of the owner of the assets to look up
123 | * @param templateId The ID of the template of a group of assets
124 | * @returns {SaleDataByAssetId} Returns object of sale asset data organized by asset ID
125 | */
126 |
127 | const getSaleDataByAssetId = async (
128 | owner: string,
129 | templateId: string
130 | ): Promise => {
131 | const { assets } = await getAllTemplateSales(templateId, owner);
132 | const sales = {};
133 | for (const asset of assets) {
134 | sales[asset.assetId] = asset;
135 | }
136 | return sales;
137 | };
138 |
139 | /**
140 | * Gets a list of all user owned assets and checks whether there are open offers.
141 | * Mostly used in viewing all your owned assets and see which one is listed for sale at a glance.
142 | * @param owner The account name of the owner of the assets to look up
143 | * @param templateId The ID of the template of a group of assets
144 | * @returns {UserTemplateAssetDetails} Returns array of assets, raw prices organized by asset ID, and sale IDs organized by asset ID
145 | */
146 |
147 | export const getUserTemplateAssets = async (
148 | owner: string,
149 | templateId: string
150 | ): Promise => {
151 | try {
152 | const assets = await getAllUserAssetsByTemplate(owner, templateId);
153 | const userOffers = await getUserOffers(owner);
154 |
155 | if (!userOffers || !userOffers.length) {
156 | return {
157 | assets,
158 | saleData: {},
159 | };
160 | }
161 |
162 | const saleData = await getSaleDataByAssetId(owner, templateId);
163 |
164 | const saleDataWithRawPrice = {};
165 | for (const assetId in saleData) {
166 | const { salePrice } = saleData[assetId];
167 | saleDataWithRawPrice[assetId] = {
168 | rawPrice: salePrice.replace(/[,]/g, ''),
169 | ...saleData[assetId],
170 | };
171 | }
172 |
173 | const assetsWithSaleData = assets.map((asset) => ({
174 | ...asset,
175 | ...saleDataWithRawPrice[asset.asset_id],
176 | }));
177 |
178 | return {
179 | assets: assetsWithSaleData,
180 | saleData: saleDataWithRawPrice,
181 | };
182 | } catch (e) {
183 | throw new Error(e);
184 | }
185 | };
186 |
187 | /**
188 | * Gets the detail of a specific asset and returns it with a "isForSale" flag
189 | * Mostly used in checking your own asset detail to determine what details to display (cancel listing, vs put up for sale).
190 | * @param {string} assetId The asset id number you're trying to look up
191 | * @return {Asset} Returns asset information, with additional flag "isForSale",
192 | * after checking if any listed sales exist for that asset_id
193 | */
194 |
195 | export const getAssetDetails = async (assetId: string): Promise => {
196 | const currentAssetResponse = await getFromApi(
197 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/assets/${assetId}`
198 | );
199 |
200 | if (!currentAssetResponse.success) {
201 | throw new Error((currentAssetResponse.message as unknown) as string);
202 | }
203 |
204 | const saleForThisAsset = await getAssetSale(assetId);
205 |
206 | let isForSale = false;
207 | let salePrice = '';
208 | let saleId = '';
209 |
210 | if (saleForThisAsset && saleForThisAsset.length > 0) {
211 | const [sale] = saleForThisAsset;
212 | const {
213 | listing_price,
214 | listing_symbol,
215 | sale_id,
216 | price: { token_precision },
217 | } = sale;
218 | isForSale = true;
219 | saleId = sale_id;
220 | salePrice = `${addPrecisionDecimal(
221 | listing_price,
222 | token_precision
223 | )} ${listing_symbol}`;
224 | }
225 |
226 | return {
227 | ...currentAssetResponse.data,
228 | isForSale,
229 | salePrice,
230 | saleId,
231 | };
232 | };
233 |
--------------------------------------------------------------------------------
/components/SalesHistoryTable/index.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from 'react';
2 | import TableHeaderRow from '../TableHeaderRow';
3 | import TableHeaderCell from '../TableHeaderCell';
4 | import TableRow from '../TableRow';
5 | import TableContentWrapper from '../TableContentWraper';
6 | import SalesHistoryTableCell from '../SalesHistoryTableCell';
7 | import PaginationButton from '../../components/PaginationButton';
8 | import { addPrecisionDecimal, parseTimestamp } from '../../utils';
9 | import { StyledTable } from './SalesHistoryTable.styled';
10 | import { useWindowSize } from '../../hooks';
11 | import { getFromApi } from '../../utils/browser-fetch';
12 | import { useAuthContext } from '../Provider';
13 | import { getSalesHistoryForAsset, SaleAsset, Sale } from '../../services/sales';
14 | import { Asset } from '../../services/assets';
15 | import { PAGINATION_LIMIT } from '../../utils/constants';
16 |
17 | type Props = {
18 | tableData: Sale[];
19 | error?: string;
20 | asset?: Partial & Partial;
21 | };
22 |
23 | type TableHeader = {
24 | title: string;
25 | id: string;
26 | };
27 |
28 | type GetSalesOptions = {
29 | assetId: string;
30 | page?: number;
31 | };
32 |
33 | const salesHistoryTableHeaders = [
34 | { title: '', id: 'img' },
35 | { title: 'BUYER', id: 'buyer' },
36 | { title: 'PRICE', id: 'price' },
37 | { title: 'SERIAL', id: 'serial' },
38 | { title: 'DATE/TIME', id: 'date' },
39 | { title: 'TX', id: 'tx' },
40 | ];
41 |
42 | const mobileSalesHistoryTableHeaders = [
43 | { title: '', id: 'img' },
44 | { title: 'BUYER', id: 'buyer' },
45 | { title: 'PRICE', id: 'price' },
46 | { title: 'TX', id: 'tx' },
47 | ];
48 |
49 | const getAvatars = async (
50 | chainAccounts: string[]
51 | ): Promise<{ [chainAccount: string]: string }> => {
52 | try {
53 | const queryString = chainAccounts
54 | .map((account) => encodeURIComponent(account))
55 | .join('&accounts=');
56 |
57 | const res = await getFromApi<{ [account: string]: string }>(
58 | `/api/profile?accounts=${queryString}`
59 | );
60 |
61 | if (!res.success) {
62 | throw new Error((res.message as unknown) as string);
63 | }
64 |
65 | return res.message;
66 | } catch (e) {
67 | throw new Error(e);
68 | }
69 | };
70 |
71 | const getMySalesHistory = async ({
72 | assetId,
73 | page,
74 | }: GetSalesOptions): Promise => {
75 | try {
76 | const pageParam = page ? page : 1;
77 | const result = await getSalesHistoryForAsset(assetId, pageParam);
78 |
79 | return result;
80 | } catch (e) {
81 | throw new Error(e);
82 | }
83 | };
84 |
85 | const SalesHistoryTable = ({ tableData, error, asset }: Props): JSX.Element => {
86 | const { currentUser } = useAuthContext();
87 | const [avatars, setAvatars] = useState({});
88 | const [isLoading, setIsLoading] = useState(true);
89 | const [isLoadingNextPage, setIsLoadingNextPage] = useState(true);
90 | const [renderedData, setRenderedData] = useState([]);
91 | const [prefetchedData, setPrefetchedData] = useState([]);
92 | const [prefetchPageNumber, setPrefetchPageNumber] = useState(2);
93 | const [errorMessage, setErrorMessage] = useState(error);
94 | const [tableHeaders, setTableHeaders] = useState([]);
95 | const { isMobile } = useWindowSize();
96 |
97 | useEffect(() => {
98 | setRenderedData(tableData);
99 | }, [tableData]);
100 |
101 | useEffect(() => {
102 | if (isMobile) {
103 | setTableHeaders(mobileSalesHistoryTableHeaders);
104 | } else {
105 | setTableHeaders(salesHistoryTableHeaders);
106 | }
107 | }, [isMobile]);
108 |
109 | useEffect(() => {
110 | (async () => {
111 | try {
112 | if (renderedData.length) {
113 | const chainAccounts = renderedData.map(({ buyer }) => buyer);
114 | const res = await getAvatars(chainAccounts);
115 | setAvatars(res);
116 | }
117 | if (renderedData.length % PAGINATION_LIMIT == 0) {
118 | await prefetchNextPage();
119 | }
120 | } catch (e) {
121 | setErrorMessage(e.message);
122 | }
123 | setIsLoading(false);
124 | })();
125 | }, [renderedData]);
126 |
127 | useEffect(() => {
128 | if (currentUser) {
129 | setAvatars({
130 | ...avatars,
131 | [currentUser.actor]: currentUser.avatar,
132 | });
133 | }
134 | }, [currentUser]);
135 |
136 | const getTableContent = () => {
137 | return renderedData.map((sale) => {
138 | return (
139 |
140 | {tableHeaders.map(({ id }) => {
141 | const content = getCellContent(sale, id, avatars);
142 | return ;
143 | })}
144 |
145 | );
146 | });
147 | };
148 |
149 | const prefetchNextPage = async () => {
150 | const prefetchedResult = await getMySalesHistory({
151 | assetId: asset.assetId || asset.asset_id,
152 | page: prefetchPageNumber,
153 | });
154 |
155 | setPrefetchedData(prefetchedResult as Sale[]);
156 |
157 | if (!prefetchedResult.length) {
158 | setPrefetchPageNumber(-1);
159 | } else {
160 | setPrefetchPageNumber(prefetchPageNumber + 1);
161 | }
162 |
163 | setIsLoadingNextPage(false);
164 | };
165 |
166 | const showNextPage = async () => {
167 | const allFetchedData = renderedData.concat(prefetchedData);
168 | setRenderedData(allFetchedData);
169 | setIsLoading(true);
170 | setIsLoadingNextPage(true);
171 | await prefetchNextPage();
172 | };
173 |
174 | return (
175 | <>
176 |
177 |
178 |
179 | {tableHeaders.map((header) => {
180 | return (
181 |
182 | {header.title}
183 |
184 | );
185 | })}
186 |
187 |
188 |
189 |
199 | {getTableContent()}
200 |
201 |
202 |
203 |
209 | >
210 | );
211 | };
212 |
213 | const getCellContent = (sale, id, avatars) => {
214 | switch (id) {
215 | case 'img': {
216 | return avatars[sale.buyer];
217 | }
218 | case 'buyer': {
219 | return sale.buyer;
220 | }
221 | case 'price': {
222 | const { amount, token_precision, token_symbol } = sale.price;
223 | const price = `${addPrecisionDecimal(
224 | amount,
225 | token_precision
226 | )} ${token_symbol}`;
227 | return price;
228 | }
229 | case 'serial': {
230 | const { assets, asset_serial } = sale;
231 | const asset = assets[0];
232 | const serial = asset.template_mint;
233 | return asset_serial || serial;
234 | }
235 | case 'date': {
236 | const timeInUnix = sale.updated_at_time;
237 | const date = parseTimestamp(timeInUnix);
238 | return date;
239 | }
240 | case 'tx': {
241 | return sale.updated_at_block;
242 | }
243 | }
244 | };
245 |
246 | export default SalesHistoryTable;
247 |
--------------------------------------------------------------------------------
/services/sales.ts:
--------------------------------------------------------------------------------
1 | import { Asset } from './assets';
2 | import { Collection } from './templates';
3 | import { getFromApi } from '../utils/browser-fetch';
4 | import { toQueryString, addPrecisionDecimal } from '../utils';
5 | import { TOKEN_SYMBOL, PAGINATION_LIMIT } from '../utils/constants';
6 |
7 | type Price = {
8 | token_contract: string;
9 | token_symbol: string;
10 | token_precision: number;
11 | median: number | null;
12 | amount: number;
13 | };
14 |
15 | export type SaleAsset = {
16 | saleId: string;
17 | assetId: string;
18 | templateMint: string;
19 | salePrice: string;
20 | };
21 |
22 | export type Sale = {
23 | market_contract: string;
24 | assets_contract: string;
25 | sale_id: string;
26 | seller: string;
27 | buyer: string;
28 | offer_id: string;
29 | price: Price;
30 | listing_price: string;
31 | listing_symbol: string;
32 | assets: Asset[];
33 | collection_name: string;
34 | collection: Collection;
35 | maker_marketplace: string;
36 | taker_marketplace: string;
37 | is_seller_contract: boolean;
38 | updated_at_block: string;
39 | updated_at_time: string;
40 | created_at_block: string;
41 | created_at_time: string;
42 | state: number;
43 | asset_serial: string;
44 | };
45 |
46 | export type SaleAssetRecord = {
47 | rawPrices: {
48 | [templateMint: string]: string;
49 | };
50 | formattedPrices: {
51 | [templateMint: string]: string;
52 | };
53 | assets: SaleAsset[];
54 | };
55 |
56 | /**
57 | * Get the fulfilled sales for a specific templates (sales that were successful)
58 | * Mostly used in viewing sales history of a specific template
59 | * @param {string} templateId The template id of the history you want to look up
60 | * @param {number} page The page to look up from atomicassets api if number of assets returned is greater than given limit (API defaults to a limit of 100)
61 | * @return {Sales[]} Returns an array of Sales for that specific template id
62 | */
63 |
64 | export const getSalesHistoryForTemplate = async (
65 | templateId: string,
66 | page?: number
67 | ): Promise => {
68 | try {
69 | const pageParam = page ? page : 1;
70 | const queryObject = {
71 | state: '3', // Valid sale, Sale was bought
72 | template_id: templateId,
73 | sort: 'updated',
74 | order: 'desc',
75 | page: pageParam,
76 | limit: PAGINATION_LIMIT,
77 | };
78 | const queryString = toQueryString(queryObject);
79 | const latestSalesRes = await getFromApi(
80 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicmarket/v1/sales?${queryString}`
81 | );
82 |
83 | if (!latestSalesRes.success) {
84 | throw new Error((latestSalesRes.message as unknown) as string);
85 | }
86 |
87 | return latestSalesRes.data;
88 | } catch (e) {
89 | throw new Error(e);
90 | }
91 | };
92 |
93 | /**
94 | * Get the fulfilled sales for a specific asset (sales that were successful)
95 | * Mostly used in viewing sales history of a specific asset
96 | * @param {string} assetId The asset id of the history you want to look up
97 | * @param {number} page The page to look up from atomicassets api if number of assets returned is greater than given limit (API defaults to a limit of 100)
98 | * @return {Sales[]} Returns an array of Sales for that specific template id
99 | */
100 |
101 | export const getSalesHistoryForAsset = async (
102 | assetId: string,
103 | page?: number
104 | ): Promise => {
105 | try {
106 | const pageParam = page ? page : 1;
107 | const queryObject = {
108 | state: '3', // Valid sale, Sale was bought
109 | asset_id: assetId,
110 | sort: 'updated',
111 | order: 'desc',
112 | page: pageParam,
113 | limit: PAGINATION_LIMIT,
114 | };
115 | const queryString = toQueryString(queryObject);
116 | const latestSalesRes = await getFromApi(
117 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicmarket/v1/sales?${queryString}`
118 | );
119 |
120 | if (!latestSalesRes.success) {
121 | throw new Error((latestSalesRes.message as unknown) as string);
122 | }
123 |
124 | return latestSalesRes.data;
125 | } catch (e) {
126 | throw new Error(e);
127 | }
128 | };
129 |
130 | /**
131 | * Get the unfulfilled sale for a specific asset
132 | * @param assetId Id of the asset listed for sale
133 | * @param seller Owner of the asset listed for sale
134 | * @returns {SaleAsset[]}
135 | */
136 |
137 | export const getAssetSale = async (
138 | assetId: string,
139 | seller?: string
140 | ): Promise => {
141 | try {
142 | const queryObject = {
143 | asset_id: assetId,
144 | state: '1', // assets listed for sale
145 | seller: seller ? seller : '',
146 | };
147 |
148 | const queryString = toQueryString(queryObject);
149 |
150 | const result = await getFromApi(
151 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicmarket/v1/sales?${queryString}`
152 | );
153 |
154 | if (!result.success) {
155 | throw new Error((result.message as unknown) as string);
156 | }
157 |
158 | return result.data;
159 | } catch (e) {
160 | throw new Error(e);
161 | }
162 | };
163 |
164 | /**
165 | * Get the unfulfilled sales for a specific template
166 | * Mostly used in purchasing an asset of a specific template
167 | * @param {string} templateId The template id of an asset you want to purchase
168 | * @return {SaleAssetRecord} Returns a SaleAssetRecord including a record of prices by sale ID and an array of assets for sale
169 | */
170 |
171 | export const getAllTemplateSales = async (
172 | templateId: string,
173 | owner?: string
174 | ): Promise => {
175 | try {
176 | const limit = 100;
177 | let sales = [];
178 | let hasResults = true;
179 | let page = 1;
180 |
181 | while (hasResults) {
182 | const queryObject = {
183 | state: '1',
184 | sort: 'template_mint',
185 | order: 'asc',
186 | template_id: templateId,
187 | page,
188 | owner: owner || '',
189 | symbol: TOKEN_SYMBOL,
190 | limit,
191 | };
192 | const queryParams = toQueryString(queryObject);
193 | const result = await getFromApi(
194 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicmarket/v1/sales?${queryParams}`
195 | );
196 |
197 | if (!result.success) {
198 | throw new Error((result.message as unknown) as string);
199 | }
200 |
201 | if (result.data.length < limit) {
202 | hasResults = false;
203 | }
204 |
205 | sales = sales.concat(result.data);
206 | page += 1;
207 | }
208 |
209 | let saleAssets = [];
210 | const pricesBySaleId = {};
211 | const pricesBySaleIdRaw = {};
212 | for (const sale of sales) {
213 | const {
214 | assets,
215 | listing_price,
216 | listing_symbol,
217 | sale_id,
218 | price: { token_precision },
219 | } = sale;
220 |
221 | const salePrice = `${addPrecisionDecimal(
222 | listing_price,
223 | token_precision
224 | )} ${listing_symbol}`;
225 |
226 | const rawListingPrice = `${addPrecisionDecimal(
227 | listing_price,
228 | token_precision,
229 | true
230 | )} ${listing_symbol}`;
231 | pricesBySaleId[sale_id] = salePrice;
232 | pricesBySaleIdRaw[sale_id] = rawListingPrice;
233 |
234 | const formattedAssets = assets.map(({ template_mint, asset_id }) => ({
235 | saleId: sale_id,
236 | templateMint: template_mint,
237 | salePrice,
238 | assetId: asset_id,
239 | }));
240 |
241 | saleAssets = saleAssets.concat(formattedAssets);
242 | }
243 |
244 | return {
245 | formattedPrices: pricesBySaleId,
246 | rawPrices: pricesBySaleIdRaw,
247 | assets: saleAssets,
248 | };
249 | } catch (e) {
250 | throw new Error(e);
251 | }
252 | };
253 |
254 | export const getLowestPriceAsset = async (
255 | collection: string,
256 | templateId: string
257 | ): Promise => {
258 | try {
259 | const queryObject = {
260 | collection_name: collection,
261 | template_id: templateId,
262 | sort: 'price',
263 | order: 'asc',
264 | state: '1', // assets listed for sale
265 | limit: '1',
266 | symbol: TOKEN_SYMBOL,
267 | };
268 | const queryString = toQueryString(queryObject);
269 |
270 | const saleRes = await getFromApi(
271 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicmarket/v1/sales?${queryString}`
272 | );
273 |
274 | if (!saleRes.success) {
275 | throw new Error((saleRes.message as unknown) as string);
276 | }
277 |
278 | return saleRes.data;
279 | } catch (e) {
280 | throw new Error(e);
281 | }
282 | };
283 |
--------------------------------------------------------------------------------
/services/templates.ts:
--------------------------------------------------------------------------------
1 | import { getFromApi } from '../utils/browser-fetch';
2 | import { Sale, getLowestPriceAsset } from './sales';
3 | import { addPrecisionDecimal, toQueryString } from '../utils/';
4 | import {
5 | TOKEN_SYMBOL,
6 | DEFAULT_COLLECTION,
7 | PAGINATION_LIMIT,
8 | } from '../utils/constants';
9 |
10 | export type SchemaFormat = {
11 | name: string;
12 | type: string;
13 | };
14 |
15 | export type Schema = {
16 | schema_name: string;
17 | format: SchemaFormat[];
18 | created_at_block: string;
19 | created_at_time: string;
20 | };
21 |
22 | export type Collection = {
23 | author: string;
24 | collection_name: string;
25 | name?: string | null;
26 | img?: string | null;
27 | allow_notify?: boolean;
28 | authorized_accounts?: string[];
29 | notify_accounts?: string[] | [];
30 | market_fee?: number;
31 | created_at_block?: string;
32 | created_at_time?: string;
33 | };
34 |
35 | type ImmutableData = {
36 | name: string;
37 | image: string;
38 | series: number;
39 | };
40 |
41 | export interface Template {
42 | immutable_data?: ImmutableData;
43 | template_id?: string;
44 | contract?: string;
45 | collection?: Collection;
46 | schema?: Schema;
47 | name?: string;
48 | max_supply?: string;
49 | is_transferable?: boolean;
50 | is_burnable?: boolean;
51 | created_at_time?: string;
52 | created_at_block?: string;
53 | issued_supply?: string;
54 | lowestPrice?: string;
55 | totalAssets?: string;
56 | assetsForSale?: string;
57 | }
58 |
59 | type GetCollectionOptions = {
60 | type: string;
61 | page?: number;
62 | };
63 |
64 | export type Account = {
65 | assets: number;
66 | collections: Collection[];
67 | templates: {
68 | assets: string;
69 | collection_name: string;
70 | template_id: string;
71 | }[];
72 | };
73 |
74 | type formatTemplatesWithLowPriceAndAssetCountProps = {
75 | templateIds: string[];
76 | templates: Template[];
77 | assetCountById: {
78 | [templateId: string]: string;
79 | };
80 | assetCountByIdWithHidden: {
81 | [templateId: string]: string;
82 | };
83 | lowPriceById: {
84 | [templateId: string]: string;
85 | };
86 | };
87 |
88 | /**
89 | * Get a specific template detail
90 | * Mostly used in viewing a specific template's detail page
91 | * @param {string} collectionName The name of the collection the template belongs in
92 | * @param {string} templateId The specific template id number you need to look up details for
93 | * @return {Template[]} Returns array of templates, most likely will only return one item in the array
94 | */
95 |
96 | export const getTemplateDetails = async (
97 | collectionName: string,
98 | templateId: string
99 | ): Promise => {
100 | try {
101 | const templateResponse = await getFromApi(
102 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/templates/${collectionName}/${templateId}`
103 | );
104 |
105 | if (!templateResponse.success) {
106 | throw new Error((templateResponse.message as unknown) as string);
107 | }
108 |
109 | const saleForTemplateAsc = await getLowestPriceAsset(
110 | collectionName,
111 | templateId
112 | );
113 | const lowestPriceSale = saleForTemplateAsc[0];
114 | const lowestPrice =
115 | lowestPriceSale && lowestPriceSale.listing_price
116 | ? `${addPrecisionDecimal(
117 | lowestPriceSale.listing_price,
118 | lowestPriceSale.price.token_precision
119 | )} ${lowestPriceSale.listing_symbol}`
120 | : '';
121 |
122 | return {
123 | ...templateResponse.data,
124 | lowestPrice,
125 | };
126 | } catch (e) {
127 | throw new Error(e);
128 | }
129 | };
130 |
131 | /**
132 | * Get a list of all templates within a collection
133 | * Mostly used in viewing all the templates of a collection (i.e. in the homepage or after searching for one collection)
134 | * @param {string} type The name of the collection
135 | * @param {string} page Page number of results to return (defaults to 1)
136 | * @return {Template[]} Returns array of templates in that collection
137 | */
138 |
139 | export const getTemplatesByCollection = async ({
140 | type,
141 | page,
142 | }: GetCollectionOptions): Promise => {
143 | try {
144 | const templatesQueryObject = {
145 | collection_name: type,
146 | page: page || 1,
147 | limit: PAGINATION_LIMIT,
148 | };
149 |
150 | const templatesQueryParams = toQueryString(templatesQueryObject);
151 | const templatesResult = await getFromApi(
152 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/templates?${templatesQueryParams}`
153 | );
154 |
155 | if (!templatesResult.success) {
156 | const errorMessage =
157 | typeof templatesResult.error === 'object'
158 | ? templatesResult.error.message
159 | : templatesResult.message;
160 | throw new Error(errorMessage as string);
161 | }
162 |
163 | return templatesResult.data;
164 | } catch (e) {
165 | throw new Error(e);
166 | }
167 | };
168 |
169 | /**
170 | * Gets the lowest price of assets for sale for a collection's templates
171 | * Mostly used to display the lowest price of any of the templates with assets for sale in the collection
172 | * @param {string} type Name of collection that templates belong to
173 | * @return {Template[]} Returns array of templates with an additional 'lowestPrice' flag
174 | */
175 |
176 | export const getLowestPricesForAllCollectionTemplates = async ({
177 | type,
178 | }: {
179 | type: string;
180 | }): Promise<{ [id: string]: string }> => {
181 | const statsResults = await getFromApi<{ templates: number }>(
182 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/collections/${type}/stats`
183 | );
184 |
185 | if (!statsResults.success) {
186 | const errorMessage =
187 | typeof statsResults.error === 'object'
188 | ? statsResults.error.message
189 | : statsResults.message;
190 | throw new Error(errorMessage as string);
191 | }
192 |
193 | const numberOfTemplates = statsResults.data.templates;
194 |
195 | const salesQueryObject = {
196 | collection_name: type,
197 | symbol: TOKEN_SYMBOL,
198 | order: 'desc',
199 | // sort: 'created',
200 | limit: numberOfTemplates,
201 | };
202 |
203 | const salesQueryParams = toQueryString(salesQueryObject);
204 | const salesResult = await getFromApi(
205 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicmarket/v1/sales/templates?${salesQueryParams}`
206 | );
207 |
208 | if (!salesResult.success) {
209 | const errorMessage =
210 | typeof salesResult.error === 'object'
211 | ? salesResult.error.message
212 | : salesResult.message;
213 | throw new Error(errorMessage as string);
214 | }
215 |
216 | const lowestPriceByTemplateIds = {};
217 | for (const sale of salesResult.data) {
218 | const {
219 | listing_price,
220 | assets,
221 | price: { token_precision },
222 | } = sale;
223 |
224 | if (!assets.length) {
225 | continue;
226 | }
227 |
228 | const {
229 | template: { template_id },
230 | } = assets[0];
231 |
232 | lowestPriceByTemplateIds[template_id] = listing_price
233 | ? `${addPrecisionDecimal(listing_price, token_precision)} ${TOKEN_SYMBOL}`
234 | : '';
235 | }
236 |
237 | return lowestPriceByTemplateIds;
238 | };
239 |
240 | /**
241 | * Formats an array of templates with a custom 'lowestPrice' flag
242 | * Mostly used to display the lowest price of any of the templates with assets for sale in the collection
243 | * @param {string} templates Array of templates to format
244 | * @param {string} lowestPrices Object of a collection's lowest priced assets organized by template ID
245 | * @return {Template[]} Returns array of templates with an additional 'lowestPrice' flag
246 | */
247 |
248 | export const formatTemplatesWithPriceData = (
249 | templates: Template[],
250 | lowestPrices: { [id: string]: string }
251 | ): Template[] =>
252 | templates.map((template) => ({
253 | ...template,
254 | lowestPrice: lowestPrices[template.template_id] || '',
255 | }));
256 |
257 | /***
258 | * Function returns templates with the following added keys: (used primarily for diaplying user's owned assets in My NFT page)
259 | * totalAssets: Total number of assets owned by 'owner'
260 | * assetsForSale: Number of assets for sale by 'owner'
261 | * lowestPrice: Lowest price of an asset for sale in marketplace
262 | * @param {string} owner Owner of assets to look up
263 | * @param {number} page Reference for pagination if number of template categories (based on owned assets) is greater than number of templates displayed per page
264 | * @return {Template[]}
265 | */
266 |
267 | export const getTemplatesWithUserAssetCount = async (
268 | owner: string,
269 | page: number
270 | ): Promise => {
271 | try {
272 | const accountResponse = await getFromApi(
273 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/accounts/${owner}?collection_whitelist=${DEFAULT_COLLECTION}`
274 | );
275 |
276 | if (!accountResponse.success) {
277 | throw new Error((accountResponse.message as unknown) as string);
278 | }
279 |
280 | const accountResponseWithHidden = await getFromApi(
281 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/accounts/${owner}?hide_offers=true&collection_whitelist=${DEFAULT_COLLECTION}`
282 | );
283 |
284 | if (!accountResponseWithHidden.success) {
285 | throw new Error((accountResponseWithHidden.message as unknown) as string);
286 | }
287 |
288 | const userAssetsByTemplateId = {};
289 | accountResponse.data.templates.map(({ assets, template_id }) => {
290 | userAssetsByTemplateId[template_id] = assets;
291 | });
292 |
293 | const userAssetsWithHiddenByTemplateId = {};
294 | accountResponseWithHidden.data.templates.map(({ assets, template_id }) => {
295 | userAssetsWithHiddenByTemplateId[template_id] = assets;
296 | });
297 |
298 | const templateIds = accountResponse.data.templates
299 | .map(({ template_id }) => template_id)
300 | .splice((page - 1) * 10, 10);
301 |
302 | if (!templateIds.length) return [];
303 |
304 | const templates = await getTemplatesFromTemplateIds(templateIds);
305 |
306 | const lowestPricesByTemplateId = await getLowestPricesForAllCollectionTemplates(
307 | { type: DEFAULT_COLLECTION }
308 | );
309 |
310 | const templatesWithAssetsForSaleCount = formatTemplatesWithLowPriceAndAssetCount(
311 | {
312 | templateIds: templateIds,
313 | templates: templates,
314 | assetCountById: userAssetsByTemplateId,
315 | assetCountByIdWithHidden: userAssetsWithHiddenByTemplateId,
316 | lowPriceById: lowestPricesByTemplateId,
317 | }
318 | );
319 | return templatesWithAssetsForSaleCount;
320 | } catch (e) {
321 | throw new Error(e);
322 | }
323 | };
324 |
325 | /**
326 | * Function to add total asset count, assets for sale, and lowest price to template data for each template
327 | * Used in conjunction with function getTemplatesWithUserAssetCount
328 | * @param templateIds list of templateIds of templates to add data to
329 | * @param templates templates of the template Ids listed in templateIds param
330 | * @param assetCountById total number of assets for each template that user owns
331 | * @param assetCountByIdWithHidden total number of assets for each template that user owns minus those currently offered for sale
332 | * @param lowPriceById lowest price of asset currently on offer for each template
333 | * @returns {Template[]}
334 | */
335 |
336 | const formatTemplatesWithLowPriceAndAssetCount = ({
337 | templateIds,
338 | templates,
339 | assetCountById,
340 | assetCountByIdWithHidden,
341 | lowPriceById,
342 | }: formatTemplatesWithLowPriceAndAssetCountProps) => {
343 | const templatesWithAssetsForSaleCount = templateIds.map((templateId) => {
344 | const template = templates.find(({ template_id }) => {
345 | return templateId == template_id;
346 | });
347 | if (template) {
348 | template.totalAssets = `${assetCountById[templateId]}`;
349 |
350 | const assetsForSale =
351 | parseInt(assetCountById[templateId]) -
352 | parseInt(assetCountByIdWithHidden[templateId] || '0');
353 |
354 | template.assetsForSale = `${assetsForSale}`;
355 | template.lowestPrice = lowPriceById[templateId];
356 | }
357 | return template;
358 | });
359 | return templatesWithAssetsForSaleCount;
360 | };
361 |
362 | /**
363 | * Function to get templates using an array of tempalte ids as reference
364 | * @param templateIds templatesIds to grab templates for
365 | * @returns {Template[]}
366 | */
367 |
368 | export const getTemplatesFromTemplateIds = async (
369 | templateIds: string[]
370 | ): Promise => {
371 | try {
372 | const templatesQueryObject = {
373 | symbol: TOKEN_SYMBOL,
374 | collection_name: DEFAULT_COLLECTION,
375 | ids: templateIds.join(','),
376 | };
377 |
378 | const templatesQueryParams = toQueryString(templatesQueryObject);
379 | const templatesResponse = await getFromApi(
380 | `${process.env.NEXT_PUBLIC_NFT_ENDPOINT}/atomicassets/v1/templates?${templatesQueryParams}`
381 | );
382 |
383 | if (!templatesResponse.success) {
384 | throw new Error((templatesResponse.message as unknown) as string);
385 | }
386 |
387 | return templatesResponse.data;
388 | } catch (e) {
389 | throw new Error(e);
390 | }
391 | };
392 |
--------------------------------------------------------------------------------
/services/proton.ts:
--------------------------------------------------------------------------------
1 | import { ConnectWallet } from '@proton/web-sdk';
2 | import { LinkSession, Link } from '@proton/link';
3 | import logoUrl from '../public/logo.svg';
4 | import { TOKEN_CONTRACT } from '../utils/constants';
5 |
6 | export interface User {
7 | actor: string;
8 | avatar: string;
9 | name: string;
10 | isLightKYCVerified: boolean;
11 | permission: string;
12 | }
13 |
14 | interface CreateSaleOptions {
15 | seller: string;
16 | asset_id: string;
17 | price: string;
18 | currency: string;
19 | }
20 |
21 | interface CreateMultipleSalesOptions
22 | extends Omit {
23 | assetIds: string[];
24 | }
25 |
26 | interface PurchaseSaleOptions {
27 | buyer: string;
28 | amount: string;
29 | sale_id: string;
30 | }
31 |
32 | interface SaleOptions {
33 | actor: string;
34 | sale_id: string;
35 | }
36 |
37 | interface CancelMultipleSalesOptions {
38 | actor: string;
39 | saleIds: string[];
40 | }
41 |
42 | interface DepositWithdrawOptions {
43 | actor: string;
44 | amount: string;
45 | }
46 | interface DepositWithdrawResponse {
47 | success: boolean;
48 | transactionId?: string;
49 | error?: string;
50 | }
51 |
52 | interface SaleResponse {
53 | success: boolean;
54 | transactionId?: string;
55 | error?: string;
56 | }
57 |
58 | interface WalletResponse {
59 | user: User;
60 | error: string;
61 | }
62 |
63 | class ProtonSDK {
64 | chainId: string;
65 | endpoints: string[];
66 | appName: string;
67 | requestAccount: string;
68 | session: LinkSession | null;
69 | link: Link | null;
70 |
71 | constructor() {
72 | this.chainId = process.env.NEXT_PUBLIC_CHAIN_ID;
73 | this.endpoints = [process.env.NEXT_PUBLIC_CHAIN_ENDPOINT];
74 | this.appName = 'Monster NFTs';
75 | this.requestAccount = 'monsters';
76 | this.session = null;
77 | this.link = null;
78 | }
79 |
80 | connect = async ({ restoreSession }): Promise => {
81 | const { link, session } = await ConnectWallet({
82 | linkOptions: {
83 | chainId: this.chainId,
84 | endpoints: this.endpoints,
85 | restoreSession,
86 | },
87 | transportOptions: {
88 | requestAccount: this.requestAccount,
89 | backButton: true,
90 | },
91 | selectorOptions: {
92 | appName: this.appName,
93 | appLogo: logoUrl as string,
94 | },
95 | });
96 | this.link = link;
97 | this.session = session;
98 | };
99 |
100 | login = async (): Promise => {
101 | try {
102 | await this.connect({ restoreSession: false });
103 | if (!this.session || !this.session.auth || !this.session.accountData) {
104 | throw new Error('An error has occurred while logging in');
105 | }
106 | const { auth, accountData } = this.session;
107 | const profile = accountData
108 | ? accountData[0]
109 | : {
110 | name: '',
111 | acc: auth.actor,
112 | avatar: '',
113 | isLightKYCVerified: false,
114 | };
115 |
116 | const { avatar, isLightKYCVerified, name } = profile;
117 | const chainAccountAvatar = avatar
118 | ? `data:image/jpeg;base64,${avatar}`
119 | : '/default-avatar.png';
120 |
121 | return {
122 | user: {
123 | actor: auth.actor,
124 | avatar: chainAccountAvatar,
125 | isLightKYCVerified,
126 | name,
127 | permission: auth.permission,
128 | },
129 | error: '',
130 | };
131 | } catch (e) {
132 | return {
133 | user: null,
134 | error: e.message || 'An error has occurred while logging in',
135 | };
136 | }
137 | };
138 |
139 | logout = async () => {
140 | await this.link.removeSession(this.requestAccount, this.session.auth);
141 | };
142 |
143 | restoreSession = async () => {
144 | try {
145 | await this.connect({ restoreSession: true });
146 | if (!this.session || !this.session.auth || !this.session.accountData) {
147 | throw new Error('An error has occurred while restoring a session');
148 | }
149 |
150 | const { auth, accountData } = this.session;
151 | const profile = accountData
152 | ? accountData[0]
153 | : {
154 | name: '',
155 | acc: auth.actor,
156 | avatar: '',
157 | isLightKYCVerified: false,
158 | };
159 |
160 | const { avatar, isLightKYCVerified, name } = profile;
161 | const chainAccountAvatar = avatar
162 | ? `data:image/jpeg;base64,${avatar}`
163 | : '/default-avatar.png';
164 |
165 | return {
166 | user: {
167 | actor: auth.actor,
168 | avatar: chainAccountAvatar,
169 | isLightKYCVerified,
170 | name,
171 | permission: auth.permission,
172 | },
173 | error: '',
174 | };
175 | } catch (e) {
176 | return {
177 | user: null,
178 | error: e.message || 'An error has occurred while restoring a session',
179 | };
180 | }
181 | };
182 |
183 | /**
184 | * Withdraw tokens from the marketplace back into user's account
185 | *
186 | * @param {string} actor chainAccount of user
187 | * @param {string} amount amount of FOOBAR (will only be using FOOBAR in this demo, i.e 1.000000 FOOBAR)
188 | * @return {DepositWithdrawResponse} Returns an object indicating the success of the transaction and transaction ID.
189 | */
190 |
191 | withdraw = async ({
192 | actor,
193 | amount,
194 | }: DepositWithdrawOptions): Promise => {
195 | const action = [
196 | {
197 | account: 'atomicmarket',
198 | name: 'withdraw',
199 | authorization: [
200 | {
201 | actor: actor,
202 | permission: 'active',
203 | },
204 | ],
205 | data: {
206 | owner: actor,
207 | token_to_withdraw: amount,
208 | },
209 | },
210 | ];
211 | try {
212 | if (!this.session) {
213 | throw new Error('Must be logged in to withdraw from the market');
214 | }
215 |
216 | const result = await this.session.transact(
217 | { actions: action },
218 | { broadcast: true }
219 | );
220 |
221 | return {
222 | success: true,
223 | transactionId: result.processed.id,
224 | };
225 | } catch (e) {
226 | return {
227 | success: false,
228 | error:
229 | e.message ||
230 | 'An error has occured while attempting to withdraw from the market',
231 | };
232 | }
233 | };
234 |
235 | /**
236 | * Announce an asset sale and create an initial offer for the asset on atomic market.
237 | *
238 | * @param {string} seller Chain account of the asset's current owner.
239 | * @param {string} asset_id ID of the asset to sell.
240 | * @param {string} price Listing price of the sale (i.e. '1.000000').
241 | * @param {string} currency Token precision (number of decimal points) and token symbol that the sale will be paid in (i.e. '6,FOOBAR').
242 | * @return {SaleResponse} Returns an object indicating the success of the transaction and transaction ID.
243 | */
244 |
245 | createSale = async ({
246 | seller,
247 | asset_id,
248 | price,
249 | currency,
250 | }: CreateSaleOptions): Promise => {
251 | const actions = [
252 | {
253 | account: 'atomicmarket',
254 | name: 'announcesale',
255 | authorization: [
256 | {
257 | actor: seller,
258 | permission: 'active',
259 | },
260 | ],
261 | data: {
262 | seller,
263 | asset_ids: [asset_id],
264 | maker_marketplace: 'fees.market',
265 | listing_price: price,
266 | settlement_symbol: currency,
267 | },
268 | },
269 | {
270 | account: 'atomicassets',
271 | name: 'createoffer',
272 | authorization: [
273 | {
274 | actor: seller,
275 | permission: 'active',
276 | },
277 | ],
278 | data: {
279 | sender: seller,
280 | recipient: 'atomicmarket',
281 | sender_asset_ids: [asset_id],
282 | recipient_asset_ids: [],
283 | memo: 'sale',
284 | },
285 | },
286 | ];
287 |
288 | try {
289 | if (!this.session) {
290 | throw new Error('Unable to create a sale offer without logging in.');
291 | }
292 |
293 | const result = await this.session.transact(
294 | { actions: actions },
295 | { broadcast: true }
296 | );
297 |
298 | return {
299 | success: true,
300 | transactionId: result.processed.id,
301 | };
302 | } catch (e) {
303 | return {
304 | success: false,
305 | error:
306 | e.message || 'An error has occurred while creating the sale offer.',
307 | };
308 | }
309 | };
310 |
311 | /**
312 | * Announce multiple asset sales and create initial offers for the assets on atomic market.
313 | *
314 | * @param {string} seller Chain account of the asset's current owner.
315 | * @param {string[]} assetIds Array of IDs for the assets to sell.
316 | * @param {string} price Listing price of the sale (i.e. '1.000000').
317 | * @param {string} currency Token precision (number of decimal points) and token symbol that the sale will be paid in (i.e. '6,FOOBAR').
318 | * @return {SaleResponse} Returns an object indicating the success of the transaction and transaction ID.
319 | */
320 |
321 | createMultipleSales = async ({
322 | seller,
323 | assetIds,
324 | price,
325 | currency,
326 | }: CreateMultipleSalesOptions): Promise => {
327 | const announceSaleActions = assetIds.map((asset_id) => ({
328 | account: 'atomicmarket',
329 | name: 'announcesale',
330 | authorization: [
331 | {
332 | actor: seller,
333 | permission: 'active',
334 | },
335 | ],
336 | data: {
337 | seller,
338 | asset_ids: [asset_id],
339 | maker_marketplace: 'fees.market',
340 | listing_price: price,
341 | settlement_symbol: currency,
342 | },
343 | }));
344 |
345 | const createOfferActions = assetIds.map((asset_id) => ({
346 | account: 'atomicassets',
347 | name: 'createoffer',
348 | authorization: [
349 | {
350 | actor: seller,
351 | permission: 'active',
352 | },
353 | ],
354 | data: {
355 | sender: seller,
356 | recipient: 'atomicmarket',
357 | sender_asset_ids: [asset_id],
358 | recipient_asset_ids: [],
359 | memo: 'sale',
360 | },
361 | }));
362 |
363 | const actions = [...announceSaleActions, ...createOfferActions];
364 |
365 | try {
366 | if (!this.session) {
367 | throw new Error('Unable to create a sale offer without logging in.');
368 | }
369 |
370 | const result = await this.session.transact(
371 | { actions: actions },
372 | { broadcast: true }
373 | );
374 |
375 | return {
376 | success: true,
377 | transactionId: result.processed.id,
378 | };
379 | } catch (e) {
380 | return {
381 | success: false,
382 | error:
383 | e.message || 'An error has occurred while creating the sale offer.',
384 | };
385 | }
386 | };
387 |
388 | /**
389 | * Cancel the announcement of an asset sale and its initial offer on atomic market.
390 | *
391 | * @param {string} actor Chain account of the asset's current owner.
392 | * @param {string} sale_id ID of the sale to cancel.
393 | * @return {SaleResponse} Returns an object indicating the success of the transaction and transaction ID.
394 | */
395 |
396 | cancelSale = async ({
397 | actor,
398 | sale_id,
399 | }: SaleOptions): Promise => {
400 | const actions = [
401 | {
402 | account: 'atomicmarket',
403 | name: 'cancelsale',
404 | authorization: [
405 | {
406 | actor,
407 | permission: 'active',
408 | },
409 | ],
410 | data: {
411 | sale_id,
412 | },
413 | },
414 | ];
415 |
416 | try {
417 | if (!this.session) {
418 | throw new Error('Unable to cancel a sale without logging in.');
419 | }
420 |
421 | const result = await this.session.transact(
422 | { actions: actions },
423 | { broadcast: true }
424 | );
425 |
426 | return {
427 | success: true,
428 | transactionId: result.processed.id,
429 | };
430 | } catch (e) {
431 | return {
432 | success: false,
433 | error: e.message || 'An error has occurred while cancelling the sale.',
434 | };
435 | }
436 | };
437 |
438 | /**
439 | * Cancel the announcements of several asset sales and their initial offers on atomic market.
440 | *
441 | * @param {string} actor Chain account of the asset's current owner.
442 | * @param {string[]} saleIds Array of IDs for the sales to cancel.
443 | * @return {SaleResponse} Returns an object indicating the success of the transaction and transaction ID.
444 | */
445 |
446 | cancelMultipleSales = async ({
447 | actor,
448 | saleIds,
449 | }: CancelMultipleSalesOptions): Promise => {
450 | const actions = saleIds.map((sale_id) => ({
451 | account: 'atomicmarket',
452 | name: 'cancelsale',
453 | authorization: [
454 | {
455 | actor,
456 | permission: 'active',
457 | },
458 | ],
459 | data: {
460 | sale_id,
461 | },
462 | }));
463 |
464 | try {
465 | if (!this.session) {
466 | throw new Error('Unable to cancel a sale without logging in.');
467 | }
468 |
469 | const result = await this.session.transact(
470 | { actions: actions },
471 | { broadcast: true }
472 | );
473 |
474 | return {
475 | success: true,
476 | transactionId: result.processed.id,
477 | };
478 | } catch (e) {
479 | return {
480 | success: false,
481 | error: e.message || 'An error has occurred while cancelling the sale.',
482 | };
483 | }
484 | };
485 |
486 | purchaseSale = async ({
487 | buyer,
488 | amount,
489 | sale_id,
490 | }: PurchaseSaleOptions): Promise => {
491 | const actions = [
492 | {
493 | account: TOKEN_CONTRACT,
494 | name: 'transfer',
495 | authorization: [
496 | {
497 | actor: buyer,
498 | permission: 'active',
499 | },
500 | ],
501 | data: {
502 | from: buyer,
503 | to: 'atomicmarket',
504 | quantity: amount,
505 | memo: 'deposit',
506 | },
507 | },
508 | {
509 | account: 'atomicmarket',
510 | name: 'purchasesale',
511 | authorization: [
512 | {
513 | actor: buyer,
514 | permission: 'active',
515 | },
516 | ],
517 | data: {
518 | sale_id,
519 | buyer,
520 | intended_delphi_median: 0,
521 | taker_marketplace: 'fees.market',
522 | },
523 | },
524 | ];
525 | try {
526 | if (!this.session) {
527 | throw new Error('Unable to purchase a sale without logging in.');
528 | }
529 |
530 | const result = await this.session.transact(
531 | { actions: actions },
532 | { broadcast: true }
533 | );
534 |
535 | return {
536 | success: true,
537 | transactionId: result.processed.id,
538 | };
539 | } catch (e) {
540 | const message = e.message[0].toUpperCase() + e.message.slice(1);
541 | return {
542 | success: false,
543 | error:
544 | message || 'An error has occurred while trying to purchase an item.',
545 | };
546 | }
547 | };
548 | }
549 |
550 | export default new ProtonSDK();
551 |
--------------------------------------------------------------------------------