├── .env.sample ├── .eslintrc.json ├── .gitignore ├── README.md ├── components ├── DownloadModal.tsx ├── LoadingIndicator.tsx └── Modal.tsx ├── contexts └── downloadModal.tsx ├── helpers ├── format.ts └── tailwind.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.js ├── api │ └── ethpass │ │ ├── create.ts │ │ ├── get.ts │ │ └── scan.ts ├── crossmint.tsx ├── index.tsx ├── magiclink.tsx ├── rainbow.tsx └── scanner.tsx ├── postcss.config.js ├── public ├── assets │ ├── apple-wallet-add.png │ └── google-pay-add.png ├── favicon.ico └── vercel.svg ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json └── yarn.lock /.env.sample: -------------------------------------------------------------------------------- 1 | ETHPASS_API_HOST=https://api.ethpass.xyz 2 | ETHPASS_API_KEY= 3 | ALCHEMY_ID= 4 | NEXT_PUBLIC_MAGIC_LINK_API_KEY= 5 | NEXT_PUBLIC_CROSSMINT_API_KEY= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "plugin:tailwindcss/recommended", 5 | "eslint:recommended", 6 | "plugin:react/recommended", 7 | "prettier", 8 | "plugin:prettier/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": "latest", 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint", 17 | "react", 18 | "tailwindcss", 19 | "prettier" 20 | ], 21 | "rules": { 22 | "react/react-in-jsx-scope": "off", 23 | "no-case-declarations": "off", 24 | "no-unused-vars": "off", 25 | "react/prop-types": "off" 26 | } 27 | } -------------------------------------------------------------------------------- /.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 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # jetbrains IDE files 35 | .idea 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app), [`rainbow-kit`](https://github.com/rainbow-me/rainbowkit) and [`tailwind-css`](https://tailwindcss.com/) 2 | 3 | ## Getting Started 4 | 5 | First, create a file named `.env.local` in the root directory and add your API key 6 | 7 | ``` 8 | ETHPASS_API_KEY="YOUR_API_KEY" 9 | ``` 10 | 11 | Then, run the development server: 12 | 13 | ```bash 14 | npm run dev 15 | # or 16 | yarn dev 17 | ``` 18 | 19 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 20 | 21 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/ethpass](http://localhost:3000/api/ethpass). 22 | 23 | ## Examples 24 | 25 | ### Creating a pass 26 | 27 | This example contains everything you need to create your first pass. 28 | 29 | Fill out the form with the details of the NFT in the wallet you want to create a pass for. Manually inputting the required parameters is for demo purposes only. You'll likely replace this with data aggregated from your integration. E.g. (OpenSea, Alchemy, Zora) 30 | 31 | ![create](https://user-images.githubusercontent.com/3741055/180839388-13ff2ce1-4e93-40d8-a63f-59e191c2aecf.gif) 32 | 33 | ### Scanning a pass 34 | 35 | Scan passes to verify ownership and view the data you encoded in the barcode. 36 | 37 | ![scan](https://user-images.githubusercontent.com/3741055/180848044-41bb75de-1654-49c1-8ae1-dbd7a9790c88.gif) 38 | 39 | ## Documentation 40 | 41 | For full API documentation, visit [docs.ethpass.xyz](https://docs.ethpass.xyz). 42 | 43 | ## Linting 44 | The app has some eslint plugins installed for typescript, react, nextjs, and tailwind. Run `yarn lint --fix` to lint your code. 45 | 46 | ## Troubleshooting 47 | 48 | - Camera not working on mobile devices 49 | - Make sure the web server has valid SSL certificates and is available with `https://` 50 | -------------------------------------------------------------------------------- /components/DownloadModal.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { CheckIcon } from "@heroicons/react/outline"; 3 | import { useDownloadModalContext } from "contexts/downloadModal"; 4 | import Image from "next/image"; 5 | import addAppleWallet from "public/assets/apple-wallet-add.png"; 6 | import addGooglePay from "public/assets/google-pay-add.png"; 7 | import QRCode from "qrcode"; 8 | import Modal from "components/Modal"; 9 | 10 | export enum Platform { 11 | APPLE = "apple", 12 | GOOGLE = "google", 13 | } 14 | 15 | export default function DownloadModal() { 16 | const { hideModal, open, content } = useDownloadModalContext(); 17 | const [qrCode, setQRCode] = useState(null); 18 | const { fileURL, platform } = content; 19 | 20 | useEffect(() => { 21 | if (!fileURL) return; 22 | QRCode.toDataURL(fileURL, {}, function (err, url) { 23 | if (err) throw err; 24 | setQRCode(url); 25 | }); 26 | }, [fileURL]); 27 | 28 | return ( 29 | 30 |
31 |
33 |
34 |
35 |

{`Scan QR code using your ${ 36 | platform === Platform.GOOGLE ? "Android" : "Apple" 37 | } device`}

38 |
39 | QR Code 40 |
41 |

42 | Or tap below to download directly on your mobile device. 43 |

44 |
45 | {platform && platform === Platform.APPLE ? ( 46 | 47 | Add to Apple Wallet 53 | 54 | ) : ( 55 | platform && ( 56 | 57 | Add to Google Pay 63 | 64 | ) 65 | )} 66 |
67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /components/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | export default function LoadingIndicator({ asPage = false }) { 2 | const Loader = () => ( 3 |
4 |
5 |
6 |
7 |
8 | ); 9 | return asPage ? ( 10 | <> 11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 | 19 | ) : ( 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, ReactNode } from "react"; 2 | import { Dialog, Transition } from "@headlessui/react"; 3 | import { ArrowLeftIcon } from "@heroicons/react/outline"; 4 | import { classNames } from "helpers/tailwind"; 5 | 6 | interface ModalProps { 7 | isActive: boolean; 8 | onClose: () => void; 9 | title?: string; 10 | children: ReactNode; 11 | } 12 | 13 | export default function Modal({ 14 | title, 15 | children, 16 | isActive, 17 | onClose, 18 | }: ModalProps) { 19 | return ( 20 | 21 | 26 |
27 | {/* Overlay */} 28 | 37 | 38 | 39 | 40 | {/* Content */} 41 | 50 |
51 |
57 | 66 |
67 | {" "} 68 | {title && {title}} 69 |
70 |
71 | {children} 72 |
73 |
74 |
75 |
76 |
77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /contexts/downloadModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from "react"; 2 | import DownloadModal, { Platform } from "components/DownloadModal"; 3 | 4 | const DownloadModalContext = createContext({ 5 | open: false, 6 | content: { fileURL: null, platform: null }, 7 | showModal: ({ fileURL, platform }) => {}, 8 | hideModal: () => {}, 9 | }); 10 | 11 | export const useDownloadModalContext = () => { 12 | return useContext(DownloadModalContext); 13 | }; 14 | 15 | const ModalProvider = ({ children }) => { 16 | const [open, setOpen] = useState(false); 17 | const [content, setContent] = useState<{ 18 | fileURL: string; 19 | platform: Platform; 20 | } | null>({ 21 | fileURL: null, 22 | platform: null, 23 | }); 24 | 25 | const showModal = ({ fileURL, platform }) => { 26 | setContent({ fileURL, platform }); 27 | setOpen(true); 28 | }; 29 | const hideModal = () => { 30 | setOpen(false); 31 | setTimeout(() => setContent({ fileURL: null, platform: null }), 500); 32 | }; 33 | 34 | return ( 35 | 38 | {children} 39 | 40 | 41 | ); 42 | }; 43 | 44 | export { ModalProvider as DownloadModalProvider }; 45 | -------------------------------------------------------------------------------- /helpers/format.ts: -------------------------------------------------------------------------------- 1 | export const ellipsizeAddress = (address: string) => { 2 | if (!address) return null; 3 | if (address.length && address.length < 6) return address; 4 | return `${address.slice(0, 6)}...${address.slice(address.length - 4)}`; 5 | }; 6 | -------------------------------------------------------------------------------- /helpers/tailwind.ts: -------------------------------------------------------------------------------- 1 | export function classNames(...classes) { 2 | return classes.filter(Boolean).join(" "); 3 | } 4 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-sample-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@crossmint/connect": "^0.0.8", 13 | "@headlessui/react": "^1.6.6", 14 | "@heroicons/react": "^1.0.6", 15 | "@magic-ext/connect": "^3.1.0", 16 | "@rainbow-me/rainbowkit": "^0.4.2", 17 | "ethers": "^5.6.6", 18 | "magic-sdk": "^10.1.0", 19 | "moment": "^2.29.4", 20 | "next": "12.3.1", 21 | "node-fetch": "^3.2.4", 22 | "qr-scanner": "^1.4.1", 23 | "qrcode": "^1.5.0", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-hot-toast": "^2.3.0", 27 | "react-query": "^4.0.0-beta.23", 28 | "wagmi": "^0.5.9" 29 | }, 30 | "devDependencies": { 31 | "@tailwindcss/forms": "^0.5.2", 32 | "@types/react": "^17.0.38", 33 | "@typescript-eslint/eslint-plugin": "^5.41.0", 34 | "@typescript-eslint/parser": "^5.41.0", 35 | "autoprefixer": "^10.4.7", 36 | "eslint": "8.15.0", 37 | "eslint-config-next": "12.1.6", 38 | "eslint-config-prettier": "^8.5.0", 39 | "eslint-plugin-prettier": "^4.2.1", 40 | "eslint-plugin-tailwindcss": "^3.6.2", 41 | "install": "^0.13.0", 42 | "postcss": "^8.4.14", 43 | "prettier": "^2.7.1", 44 | "tailwindcss": "^3.1.6", 45 | "typescript": "^4.6.4" 46 | }, 47 | "resolutions": { 48 | "react-query": "4.0.0-beta.23" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /pages/_app.js: -------------------------------------------------------------------------------- 1 | import "@rainbow-me/rainbowkit/styles.css"; 2 | import "styles/globals.css"; 3 | 4 | import { chain, createClient, WagmiProvider, configureChains } from "wagmi"; 5 | import { getDefaultWallets, RainbowKitProvider } from "@rainbow-me/rainbowkit"; 6 | import { alchemyProvider } from "wagmi/providers/alchemy"; 7 | import { publicProvider } from "wagmi/providers/public"; 8 | import { DownloadModalProvider } from "contexts/downloadModal"; 9 | import { Toaster } from "react-hot-toast"; 10 | 11 | const { chains, provider } = configureChains( 12 | [chain.mainnet, chain.polygon, chain.optimism, chain.arbitrum], 13 | [alchemyProvider({ alchemyId: process.env.ALCHEMY_ID }), publicProvider()] 14 | ); 15 | 16 | const { connectors } = getDefaultWallets({ 17 | appName: "ethpass demo", 18 | chains, 19 | }); 20 | 21 | const wagmiClient = createClient({ 22 | autoConnect: true, 23 | persister: null, 24 | connectors, 25 | provider, 26 | }); 27 | 28 | function MyApp({ Component, pageProps }) { 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | export default MyApp; 42 | -------------------------------------------------------------------------------- /pages/api/ethpass/create.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { Platform } from "components/DownloadModal"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | switch (req.method) { 9 | case "POST": 10 | const { 11 | chainId, 12 | contractAddress, 13 | image, 14 | platform, 15 | signature, 16 | signatureMessage, 17 | tokenId, 18 | } = req.body; 19 | try { 20 | // Customize Pass 21 | let pass; 22 | if (platform === Platform.APPLE) { 23 | pass = { 24 | description: "ETHPASS API DEMO", 25 | auxiliaryFields: [], 26 | backFields: [], 27 | headerFields: [], 28 | primaryFields: [], 29 | secondaryFields: [], 30 | }; 31 | } else { 32 | pass = { 33 | messages: [], 34 | }; 35 | } 36 | 37 | // Request to create pass 38 | const payload = await fetch( 39 | `${ 40 | process.env.ETHPASS_API_HOST || "https://api.ethpass.xyz" 41 | }/api/v0/passes`, 42 | { 43 | method: "POST", 44 | body: JSON.stringify({ 45 | barcode: { 46 | message: 47 | "The contents of this message will be returned in the response payload after the pass has been scanned", 48 | }, 49 | chain: { 50 | name: "evm", 51 | network: chainId, 52 | }, 53 | nft: { 54 | contractAddress, 55 | tokenId, 56 | }, 57 | image, 58 | pass, 59 | platform, 60 | signature, 61 | signatureMessage, 62 | }), 63 | headers: new Headers({ 64 | "content-type": "application/json", 65 | "x-api-key": process.env.ETHPASS_API_KEY, 66 | }), 67 | } 68 | ); 69 | if (payload.status === 200) { 70 | const json = await payload.json(); 71 | return res.status(200).json(json); 72 | } else { 73 | const json = await payload.json(); 74 | return res.status(payload.status).send(json.message); 75 | } 76 | } catch (err) { 77 | return res.status(400).send(err.message); 78 | } 79 | 80 | default: 81 | res.setHeader("Allow", ["POST"]); 82 | res.status(405).end(`Method ${req.method} Not Allowed`); 83 | break; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /pages/api/ethpass/get.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | export default async function handler( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) { 7 | switch (req.method) { 8 | case "GET": 9 | const { id } = req.query; 10 | try { 11 | const payload = await fetch( 12 | `${ 13 | process.env.ETHPASS_API_HOST || "https://api.ethpass.xyz" 14 | }/api/v0/passes/${id}`, 15 | { 16 | method: "GET", 17 | headers: new Headers({ 18 | "content-type": "application/json", 19 | "x-api-key": process.env.ETHPASS_API_KEY, 20 | }), 21 | } 22 | ); 23 | 24 | const distribution = await fetch( 25 | `${ 26 | process.env.ETHPASS_API_HOST || "https://api.ethpass.xyz" 27 | }/api/v0/passes/${id}/distribute`, 28 | { 29 | method: "GET", 30 | headers: new Headers({ 31 | "content-type": "application/json", 32 | "x-api-key": process.env.ETHPASS_API_KEY, 33 | }), 34 | } 35 | ); 36 | 37 | const { fileURL } = await distribution.json(); 38 | 39 | if (payload.status === 200) { 40 | const json = await payload.json(); 41 | return res.status(200).json({ ...json, fileURL }); 42 | } else { 43 | const json = await payload.json(); 44 | return res.status(payload.status).send(json.message); 45 | } 46 | } catch (err) { 47 | return res.status(400).send(err.message); 48 | } 49 | 50 | default: 51 | res.setHeader("Allow", ["GET"]); 52 | res.status(405).end(`Method ${req.method} Not Allowed`); 53 | break; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /pages/api/ethpass/scan.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | export default async function handler( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) { 7 | switch (req.method) { 8 | case "GET": 9 | const { data } = req.query; 10 | try { 11 | const payload = await fetch( 12 | `${ 13 | process.env.ETHPASS_API_HOST || "https://api.ethpass.xyz" 14 | }/api/v0/scan/?data=${data}`, 15 | { 16 | method: "GET", 17 | headers: new Headers({ 18 | "content-type": "application/json", 19 | "x-api-key": process.env.ETHPASS_API_KEY, 20 | }), 21 | } 22 | ); 23 | 24 | if (payload.status === 200) { 25 | const json = await payload.json(); 26 | return res.status(200).json(json); 27 | } else { 28 | const json = await payload.json(); 29 | return res.status(payload.status).send(json.message); 30 | } 31 | } catch (err) { 32 | return res.status(400).send(err.message); 33 | } 34 | 35 | default: 36 | res.setHeader("Allow", ["GET"]); 37 | res.status(405).end(`Method ${req.method} Not Allowed`); 38 | break; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pages/crossmint.tsx: -------------------------------------------------------------------------------- 1 | import { BlockchainTypes, CrossmintEVMWalletAdapter } from "@crossmint/connect"; 2 | import { classNames } from "helpers/tailwind"; 3 | import { Platform } from "components/DownloadModal"; 4 | import { useDownloadModalContext } from "contexts/downloadModal"; 5 | import { useState } from "react"; 6 | import Head from "next/head"; 7 | import Modal from "components/Modal"; 8 | import toast from "react-hot-toast"; 9 | 10 | const _crossmintConnect = new CrossmintEVMWalletAdapter({ 11 | apiKey: process.env.NEXT_PUBLIC_CROSSMINT_API_KEY, 12 | chain: BlockchainTypes.ETHEREUM, // BlockchainTypes.ETHEREUM || BlockchainTypes.POLYGON. For solana use BlockchainTypes.SOLANA 13 | }); 14 | 15 | const requiredParams = { 16 | contractAddress: "", 17 | tokenId: "", 18 | image: "", 19 | chainId: "", 20 | platform: Platform.APPLE, 21 | }; 22 | 23 | export default function Crossmint() { 24 | const [address, setAddress] = useState(null); 25 | 26 | const [isActive, setIsActive] = useState(false); 27 | const [pending, setPending] = useState(false); 28 | const [postResult, setPostResult] = useState({}); 29 | const [getResult, setGetResult] = useState({}); 30 | const { showModal: showDownloadModal, open } = useDownloadModalContext(); 31 | 32 | const [formData, setFormData] = useState(requiredParams); 33 | 34 | const login = async () => { 35 | try { 36 | const address = await _crossmintConnect.connect(); 37 | if (address) setAddress(address); 38 | } catch (error) { 39 | console.log(error); 40 | } 41 | }; 42 | 43 | const disconnect = async () => { 44 | try { 45 | await _crossmintConnect.disconnect(); 46 | setAddress(""); 47 | setIsActive(false); 48 | } catch (error) { 49 | console.log(error); 50 | } 51 | }; 52 | 53 | const reset = () => { 54 | setPostResult(null); 55 | setGetResult(null); 56 | setFormData(requiredParams); 57 | }; 58 | 59 | // Call made to create genesis wallet pass 60 | const createPass = async () => { 61 | const signatureToast = toast.loading("Waiting for signature..."); 62 | const signatureMessage = `Sign this message to generate a test pass with ethpass.xyz\n${Date.now()}`; 63 | 64 | let signature; 65 | try { 66 | signature = await _crossmintConnect.signMessage(signatureMessage); 67 | } catch (error) { 68 | console.log(error); 69 | return; 70 | } finally { 71 | toast.dismiss(signatureToast); 72 | } 73 | 74 | const payload = { 75 | ...formData, 76 | signature, 77 | signatureMessage, 78 | barcode: { 79 | message: "Payload returned after successfully scanning a pass", 80 | }, 81 | }; 82 | setPending(true); 83 | const pendingToast = toast.loading("Generating pass..."); 84 | try { 85 | const response = await fetch("/api/ethpass/create", { 86 | method: "POST", 87 | body: JSON.stringify(payload), 88 | headers: new Headers({ 89 | "content-type": "application/json", 90 | }), 91 | }); 92 | toast.dismiss(pendingToast); 93 | if (response.status === 200) { 94 | const json = await response.json(); 95 | setPending(false); 96 | setPostResult(json); 97 | 98 | console.log("## POST Result", json); 99 | showDownloadModal({ 100 | fileURL: json.fileURL, 101 | platform: payload.platform, 102 | }); 103 | } else if (response.status === 401) { 104 | toast.error(`Unable to verify ownership: ${response.statusText}`); 105 | } else { 106 | try { 107 | const { error, message } = await response.json(); 108 | toast.error(error || message); 109 | } catch { 110 | toast.error(`${response.status}: ${response.statusText}`); 111 | } 112 | } 113 | } catch (err) { 114 | console.log("## POST ERROR", err); 115 | toast.error(err.message); 116 | } finally { 117 | setPending(false); 118 | toast.dismiss(signatureToast); 119 | } 120 | }; 121 | 122 | const apiCall = async (url: string, method: string) => { 123 | setPending(true); 124 | const loading = toast.loading("Making request..."); 125 | try { 126 | const response = await fetch(url, { 127 | method, 128 | headers: new Headers({ 129 | "content-type": "application/json", 130 | }), 131 | }); 132 | 133 | toast.dismiss(loading); 134 | const json = await response.json(); 135 | toast.success("Check console logs"); 136 | console.log(`## ${method} - ${url} Response: `, json); 137 | return json; 138 | } catch (err) { 139 | console.log(`## ${method} - ${url} Error: `, err); 140 | toast.error("Check console logs"); 141 | } finally { 142 | setPending(false); 143 | } 144 | }; 145 | 146 | // Call made to fetch pass information and/or offer the user the option to download the pass again 147 | 148 | // Call made to verify pass and return the metadata encoded in the barcode. 149 | // This call will generally be made from the device that scans the passes. 150 | 151 | const renderForm = () => { 152 | const validInput = 153 | formData.contractAddress && formData.tokenId && formData.chainId; 154 | 155 | return ( 156 |
157 | 163 |
164 | 172 | setFormData({ ...formData, contractAddress: e.target.value }) 173 | } 174 | /> 175 |
176 | 182 |
183 | 189 | setFormData({ ...formData, tokenId: e.target.value }) 190 | } 191 | /> 192 |
193 | 199 |
200 | 205 | setFormData({ ...formData, chainId: e.target.value }) 206 | } 207 | /> 208 |
209 | 215 |
216 | 221 | setFormData({ ...formData, image: e.target.value }) 222 | } 223 | /> 224 |
225 |
226 | 232 | 245 |
246 | 257 |
258 | ); 259 | }; 260 | 261 | const renderSinglePassActions = () => { 262 | return ( 263 |
264 |
265 |

266 | Pass: {postResult.id} 267 |

268 |
269 |
270 |

271 | Pass successfully created! Use the unique identifier above to 272 | make further API requests for this pass. 273 |

274 |
275 |
276 | 294 |
295 |
296 |
297 |
298 | ); 299 | }; 300 | 301 | return ( 302 | <> 303 |
304 | 305 | ethpass | Crossmint Example 306 | 307 | 308 |
309 |
310 | 318 |
319 | {address ? ( 320 |
321 |
322 | {postResult?.id ? renderSinglePassActions() : renderForm()} 323 |
324 |
325 | ) : null} 326 |
327 |
328 | 329 | setIsActive(false)}> 330 |
331 |
{address}
332 | 338 |
339 |
340 | 341 | ); 342 | } 343 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { CameraIcon, TicketIcon } from "@heroicons/react/outline"; 2 | import { useRouter } from "next/router"; 3 | 4 | const features = [ 5 | { 6 | name: "Rainbow Wallet", 7 | route: "/rainbow", 8 | description: "Using Rainbow Wallet", 9 | icon: 🌈, 10 | }, 11 | { 12 | name: "Magic Link", 13 | route: "/magiclink", 14 | description: "Using Magic Link custodial wallet", 15 | icon: ( 16 | 24 | 28 | 32 | 36 | 37 | ), 38 | }, 39 | { 40 | name: "Crossmint", 41 | route: "/crossmint", 42 | description: "Using Crossmint custodial wallet", 43 | icon: ( 44 | 49 | 53 | 54 | ), 55 | }, 56 | { 57 | name: "Scanner", 58 | route: "/scanner", 59 | description: "Web camera module for scanning passes", 60 | icon: , 61 | }, 62 | ]; 63 | 64 | export default function Example() { 65 | const router = useRouter(); 66 | 67 | return ( 68 |
69 |
70 |
71 | 72 | 76 | 80 | 81 |

82 | Sample integrations 83 |

84 |

85 | Here are some examples you can use to get started with integrating 86 | our API. 87 |

88 |
89 |
90 |
91 | {features.map((feature) => ( 92 | 113 | ))} 114 |
115 |
116 |
117 |
118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /pages/magiclink.tsx: -------------------------------------------------------------------------------- 1 | import { classNames } from "helpers/tailwind"; 2 | import { ConnectExtension } from "@magic-ext/connect"; 3 | import { ethers } from "ethers"; 4 | import { Magic } from "magic-sdk"; 5 | import { Platform } from "components/DownloadModal"; 6 | import { useDownloadModalContext } from "contexts/downloadModal"; 7 | import { useEffect, useState } from "react"; 8 | import Head from "next/head"; 9 | import toast from "react-hot-toast"; 10 | 11 | const requiredParams = { 12 | contractAddress: "", 13 | tokenId: "", 14 | image: "", 15 | chainId: "", 16 | platform: Platform.APPLE, 17 | }; 18 | 19 | export default function MagicLink() { 20 | const [address, setAddress] = useState(""); 21 | const [magic, setMagic] = useState(null); 22 | const [toggle, setToggle] = useState(null); 23 | 24 | const [pending, setPending] = useState(false); 25 | const [postResult, setPostResult] = useState({}); 26 | const [getResult, setGetResult] = useState({}); 27 | const { showModal: showDownloadModal, open } = useDownloadModalContext(); 28 | 29 | const [formData, setFormData] = useState(requiredParams); 30 | 31 | useEffect(() => { 32 | setMagic( 33 | new Magic(process.env.NEXT_PUBLIC_MAGIC_LINK_API_KEY, { 34 | network: "mainnet", 35 | locale: "en_US", 36 | extensions: [new ConnectExtension()], 37 | } as any) 38 | ); 39 | }, []); 40 | 41 | useEffect(() => { 42 | if (!magic) return; 43 | 44 | const checkIsLoggedIn = async () => { 45 | try { 46 | const walletInfo = await magic.connect.getWalletInfo(); 47 | const walletType = walletInfo.walletType; 48 | 49 | return walletType; 50 | } catch (error) { 51 | setAddress(null); 52 | console.log(error); 53 | } 54 | }; 55 | 56 | checkIsLoggedIn(); 57 | }, [magic, toggle]); 58 | 59 | const login = async () => { 60 | const provider = new ethers.providers.Web3Provider(magic.rpcProvider); 61 | provider 62 | .listAccounts() 63 | .then((accounts) => setAddress(accounts?.[0])) 64 | .catch((error) => console.log(error)); 65 | }; 66 | 67 | const showWallet = async () => { 68 | const walletInfo = await magic.connect.getWalletInfo(); 69 | const walletType = walletInfo.walletType; 70 | if (walletType === "magic") { 71 | magic.connect.showWallet().catch((error) => console.log(error)); 72 | } else { 73 | disconnect(); 74 | } 75 | setToggle(!toggle); 76 | }; 77 | 78 | const disconnect = async () => { 79 | await magic.connect.disconnect().catch((error) => { 80 | toast.error(error.rawMessage); 81 | console.log(error); 82 | }); 83 | setAddress(null); 84 | }; 85 | 86 | const reset = () => { 87 | setPostResult(null); 88 | setGetResult(null); 89 | setFormData(requiredParams); 90 | }; 91 | 92 | // Call made to create genesis wallet pass 93 | const createPass = async () => { 94 | const signatureToast = toast.loading("Waiting for signature..."); 95 | const signatureMessage = `Sign this message to generate a test pass with ethpass.xyz\n${Date.now()}`; 96 | 97 | let signature; 98 | const provider = new ethers.providers.Web3Provider(magic.rpcProvider); 99 | const signer = provider.getSigner(); 100 | try { 101 | signature = await signer.signMessage(signatureMessage); 102 | } catch (error) { 103 | console.log(error); 104 | return; 105 | } finally { 106 | toast.dismiss(signatureToast); 107 | } 108 | 109 | const payload = { 110 | ...formData, 111 | signature, 112 | signatureMessage, 113 | barcode: { 114 | message: "Payload returned after successfully scanning a pass", 115 | }, 116 | }; 117 | setPending(true); 118 | const pendingToast = toast.loading("Generating pass..."); 119 | try { 120 | const response = await fetch("/api/ethpass/create", { 121 | method: "POST", 122 | body: JSON.stringify(payload), 123 | headers: new Headers({ 124 | "content-type": "application/json", 125 | }), 126 | }); 127 | toast.dismiss(pendingToast); 128 | if (response.status === 200) { 129 | const json = await response.json(); 130 | setPending(false); 131 | setPostResult(json); 132 | 133 | console.log("## POST Result", json); 134 | showDownloadModal({ 135 | fileURL: json.fileURL, 136 | platform: payload.platform, 137 | }); 138 | } else if (response.status === 401) { 139 | toast.error(`Unable to verify ownership: ${response.statusText}`); 140 | } else { 141 | try { 142 | const { error, message } = await response.json(); 143 | toast.error(error || message); 144 | } catch { 145 | toast.error(`${response.status}: ${response.statusText}`); 146 | } 147 | } 148 | } catch (err) { 149 | console.log("## POST ERROR", err); 150 | toast.error(err.message); 151 | } finally { 152 | setPending(false); 153 | toast.dismiss(signatureToast); 154 | } 155 | }; 156 | 157 | const apiCall = async (url: string, method: string) => { 158 | setPending(true); 159 | const loading = toast.loading("Making request..."); 160 | try { 161 | const response = await fetch(url, { 162 | method, 163 | headers: new Headers({ 164 | "content-type": "application/json", 165 | }), 166 | }); 167 | 168 | toast.dismiss(loading); 169 | const json = await response.json(); 170 | toast.success("Check console logs"); 171 | console.log(`## ${method} - ${url} Response: `, json); 172 | return json; 173 | } catch (err) { 174 | console.log(`## ${method} - ${url} Error: `, err); 175 | toast.error("Check console logs"); 176 | } finally { 177 | setPending(false); 178 | } 179 | }; 180 | 181 | // Call made to fetch pass information and/or offer the user the option to download the pass again 182 | 183 | // Call made to verify pass and return the metadata encoded in the barcode. 184 | // This call will generally be made from the device that scans the passes. 185 | 186 | const renderForm = () => { 187 | const validInput = 188 | formData.contractAddress && formData.tokenId && formData.chainId; 189 | 190 | return ( 191 |
192 | 198 |
199 | 207 | setFormData({ ...formData, contractAddress: e.target.value }) 208 | } 209 | /> 210 |
211 | 217 |
218 | 224 | setFormData({ ...formData, tokenId: e.target.value }) 225 | } 226 | /> 227 |
228 | 234 |
235 | 240 | setFormData({ ...formData, chainId: e.target.value }) 241 | } 242 | /> 243 |
244 | 250 |
251 | 256 | setFormData({ ...formData, image: e.target.value }) 257 | } 258 | /> 259 |
260 |
261 | 267 | 280 |
281 | 292 |
293 | ); 294 | }; 295 | 296 | const renderSinglePassActions = () => { 297 | return ( 298 |
299 |
300 |

301 | Pass: {postResult.id} 302 |

303 |
304 |
305 |

306 | Pass successfully created! Use the unique identifier above to 307 | make further API requests for this pass. 308 |

309 |
310 |
311 | 329 |
330 |
331 |
332 |
333 | ); 334 | }; 335 | 336 | return ( 337 |
338 | 339 | ethpass | Magic Link Example 340 | 341 | 342 |
343 |
344 | 352 |
353 | {address ? ( 354 |
355 |
356 | {postResult?.id ? renderSinglePassActions() : renderForm()} 357 |
358 |
359 | ) : null} 360 |
361 |
362 | ); 363 | } 364 | -------------------------------------------------------------------------------- /pages/rainbow.tsx: -------------------------------------------------------------------------------- 1 | import { ConnectButton } from "@rainbow-me/rainbowkit"; 2 | import { Platform } from "components/DownloadModal"; 3 | import { useAccount, useSigner } from "wagmi"; 4 | import { useDownloadModalContext } from "contexts/downloadModal"; 5 | import { useEffect, useState } from "react"; 6 | import Head from "next/head"; 7 | import toast from "react-hot-toast"; 8 | import { classNames } from "helpers/tailwind"; 9 | 10 | const requiredParams = { 11 | contractAddress: "", 12 | tokenId: "", 13 | image: "", 14 | chainId: "", 15 | platform: Platform.APPLE, 16 | }; 17 | 18 | export default function Home() { 19 | const { address } = useAccount(); 20 | const { data: signer } = useSigner(); 21 | const [pending, setPending] = useState(false); 22 | const [postResult, setPostResult] = useState({}); 23 | const [getResult, setGetResult] = useState({}); 24 | const { showModal: showDownloadModal, open } = useDownloadModalContext(); 25 | 26 | const [formData, setFormData] = useState(requiredParams); 27 | 28 | useEffect(() => { 29 | if (!address) { 30 | reset(); 31 | } 32 | }, [address]); 33 | 34 | const reset = () => { 35 | setPostResult(null); 36 | setGetResult(null); 37 | setFormData(requiredParams); 38 | }; 39 | 40 | // Call made to create genesis wallet pass 41 | const createPass = async () => { 42 | const signatureToast = toast.loading("Waiting for signature..."); 43 | 44 | const signatureMessage = `Sign this message to generate a test pass with ethpass.xyz\n${Date.now()}`; 45 | const signature = await signer.signMessage(signatureMessage); 46 | toast.dismiss(signatureToast); 47 | 48 | const payload = { 49 | ...formData, 50 | signature, 51 | signatureMessage, 52 | barcode: { 53 | message: "Payload returned after successfully scanning a pass", 54 | }, 55 | }; 56 | setPending(true); 57 | const pendingToast = toast.loading("Generating pass..."); 58 | try { 59 | const response = await fetch("/api/ethpass/create", { 60 | method: "POST", 61 | body: JSON.stringify(payload), 62 | headers: new Headers({ 63 | "content-type": "application/json", 64 | }), 65 | }); 66 | toast.dismiss(pendingToast); 67 | if (response.status === 200) { 68 | const json = await response.json(); 69 | setPending(false); 70 | setPostResult(json); 71 | 72 | console.log("## POST Result", json); 73 | showDownloadModal({ 74 | fileURL: json.fileURL, 75 | platform: payload.platform, 76 | }); 77 | } else if (response.status === 401) { 78 | toast.error(`Unable to verify ownership: ${response.statusText}`); 79 | } else { 80 | try { 81 | const { error, message } = await response.json(); 82 | toast.error(error || message); 83 | } catch { 84 | toast.error(`${response.status}: ${response.statusText}`); 85 | } 86 | } 87 | } catch (err) { 88 | console.log("## POST ERROR", err); 89 | toast.error(err.message); 90 | } finally { 91 | setPending(false); 92 | toast.dismiss(signatureToast); 93 | } 94 | }; 95 | 96 | const apiCall = async (url: string, method: string) => { 97 | setPending(true); 98 | const loading = toast.loading("Making request..."); 99 | try { 100 | const response = await fetch(url, { 101 | method, 102 | headers: new Headers({ 103 | "content-type": "application/json", 104 | }), 105 | }); 106 | 107 | toast.dismiss(loading); 108 | const json = await response.json(); 109 | toast.success("Check console logs"); 110 | console.log(`## ${method} - ${url} Response: `, json); 111 | return json; 112 | } catch (err) { 113 | console.log(`## ${method} - ${url} Error: `, err); 114 | toast.error("Check console logs"); 115 | } finally { 116 | setPending(false); 117 | } 118 | }; 119 | 120 | // Call made to fetch pass information and/or offer the user the option to download the pass again 121 | 122 | // Call made to verify pass and return the metadata encoded in the barcode. 123 | // This call will generally be made from the device that scans the passes. 124 | 125 | const renderForm = () => { 126 | const validInput = 127 | formData.contractAddress && formData.tokenId && formData.chainId; 128 | 129 | return ( 130 |
131 | 137 |
138 | 146 | setFormData({ ...formData, contractAddress: e.target.value }) 147 | } 148 | /> 149 |
150 | 156 |
157 | 163 | setFormData({ ...formData, tokenId: e.target.value }) 164 | } 165 | /> 166 |
167 | 173 |
174 | 179 | setFormData({ ...formData, chainId: e.target.value }) 180 | } 181 | /> 182 |
183 | 189 |
190 | 195 | setFormData({ ...formData, image: e.target.value }) 196 | } 197 | /> 198 |
199 |
200 | 206 | 219 |
220 | 231 |
232 | ); 233 | }; 234 | console.log(getResult); 235 | const renderSinglePassActions = () => { 236 | return ( 237 |
238 |
239 |

240 | Pass: {postResult.id} 241 |

242 |
243 |
244 |

245 | Pass successfully created! Use the unique identifier above to 246 | make further API requests for this pass. 247 |

248 |
249 |
250 | 268 |
269 |
270 |
271 |
272 | ); 273 | }; 274 | return ( 275 |
276 | 277 | ethpass sample app 278 | 279 | 280 |
281 |
282 | 283 |
284 | {address ? ( 285 |
286 |
287 | {postResult?.id ? renderSinglePassActions() : renderForm()} 288 |
289 |
290 | ) : null} 291 |
292 |
293 | ); 294 | } 295 | -------------------------------------------------------------------------------- /pages/scanner.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, XIcon } from "@heroicons/react/outline"; 2 | import { Fragment, useState, useEffect, useRef } from "react"; 3 | import { Transition, Dialog } from "@headlessui/react"; 4 | import QrScanner from "qr-scanner"; 5 | import moment from "moment"; 6 | import Image from "next/image"; 7 | import { ellipsizeAddress } from "helpers/format"; 8 | import LoadingIndicator from "components/LoadingIndicator"; 9 | 10 | export default function Scanner(props) { 11 | const videoRef = useRef(null); 12 | const scannerRef = useRef(null); 13 | const [scanResult, setScanResult] = useState(null); 14 | const [pending, setPending] = useState(false); 15 | 16 | useEffect(() => { 17 | const videoElement = videoRef.current; 18 | if (!videoElement) { 19 | return; 20 | } 21 | const qrScanner = new QrScanner( 22 | videoElement, 23 | async (result) => { 24 | qrScanner.stop(); 25 | await scanPass(result.data); 26 | }, 27 | { 28 | preferredCamera: "environment", 29 | highlightScanRegion: true, 30 | } 31 | ); 32 | scannerRef.current = qrScanner; 33 | if (!scanResult) { 34 | qrScanner.start().catch((error) => console.error(error)); 35 | } 36 | return () => qrScanner.stop(); 37 | }, [scanResult]); 38 | 39 | const stop = () => { 40 | const scanner = scannerRef.current; 41 | if (!scanner) { 42 | return; 43 | } 44 | scanner.stop(); 45 | }; 46 | 47 | const start = () => { 48 | const scanner = scannerRef.current; 49 | if (!scanner) { 50 | return; 51 | } 52 | scanner.start(); 53 | }; 54 | 55 | const reset = () => { 56 | setPending(false); 57 | setTimeout(() => { 58 | setScanResult(null); 59 | }, 300); // Transition animation duration 60 | }; 61 | 62 | const scanPass = async (data?: string) => { 63 | setPending(true); 64 | try { 65 | const response = await fetch(`/api/ethpass/scan?data=${data}`, { 66 | headers: new Headers({ 67 | "content-type": "application/json", 68 | }), 69 | }); 70 | 71 | if (response.status === 200) { 72 | const json = await response.json(); 73 | setScanResult({ success: true, ...json }); 74 | } else { 75 | setScanResult({ success: false }); 76 | } 77 | } catch (err) { 78 | setScanResult({ success: false }); 79 | console.log("## ERROR", err); 80 | } 81 | }; 82 | 83 | const renderNFTDetails = () => { 84 | const nft = scanResult.nfts[0]; 85 | return ( 86 | <> 87 |

88 | NFT Details 89 |

90 | {nft?.contractAddress ? ( 91 |

92 | {" "} 93 | Contract Address: 94 | {ellipsizeAddress(nft?.contractAddress)} 95 |

96 | ) : null} 97 | {nft?.tokenId ? ( 98 |

99 | {" "} 100 | Token ID: 101 | {nft?.tokenId} 102 |

103 | ) : null} 104 |

105 | Network ID: {scanResult?.chain?.network} 106 |

107 |

108 | Ownership Status: {nft?.valid ? "Valid" : "Invalid"} 109 |

110 | 111 | ); 112 | }; 113 | 114 | const renderPassMetadata = () => { 115 | if (!scanResult) return; 116 | return ( 117 |
118 |

119 | Owner: 120 | {ellipsizeAddress(scanResult?.ownerAddress)} 121 |

122 | 123 | {scanResult?.chain?.name ? ( 124 |

125 | {" "} 126 | Chain: 127 | {scanResult?.chain?.name.toUpperCase()} 128 |

129 | ) : null} 130 | {scanResult?.lastScannedAt && ( 131 |

132 | Last scanned:{" "} 133 | {new Date(scanResult?.lastScannedAt).toLocaleString()} 134 |

135 | )} 136 | {scanResult?.expiredAt && ( 137 |

138 | Pass Expired:{" "} 139 | {new Date(scanResult?.expiredAt).toLocaleString()} 140 |

141 | )} 142 | {scanResult?.nfts?.length ? <>{renderNFTDetails()} : null} 143 |
144 | ); 145 | }; 146 | 147 | const renderIcon = () => { 148 | if (!scanResult) return; 149 | if (!scanResult?.success || scanResult.expiredAt) { 150 | return ( 151 |
152 |
153 |
155 |
156 | 160 | Pass Invalid 161 | 162 |
163 |
164 |
165 | ); 166 | } 167 | if (scanResult.lastScannedAt) { 168 | return ( 169 |
170 |
171 |
173 |
174 | 178 | Valid Pass 179 | 180 |
181 |

182 | {`This pass is valid however, it was scanned ${moment( 183 | scanResult?.lastScannedAt 184 | ).fromNow()}.`} 185 |

186 |
187 |
188 |
189 | ); 190 | } else { 191 | return ( 192 |
193 |
194 |
196 |
197 | 201 | Valid Pass 202 | 203 |
204 |
205 | ); 206 | } 207 | }; 208 | 209 | return ( 210 | <> 211 |
212 |
236 | 237 | 238 | 243 |
244 | 253 | 254 | 255 | 256 | {/* This element is to trick the browser into centering the modal contents. */} 257 | 263 | 272 |
273 |
{renderIcon()}
274 | {scanResult?.success ?
{renderPassMetadata()}
: null} 275 | {scanResult ? ( 276 |
277 |
278 | 285 |
286 |
287 | ) : ( 288 |
289 |
290 | 291 |
292 | 296 | Verifying... 297 | 298 |
299 |
300 |
301 | )} 302 |
303 |
304 |
305 |
306 |
307 | 308 | ); 309 | } 310 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/apple-wallet-add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eth-pass/nextjs-sample-app/8a6ce37ad05913797260553aeaa834192fb3699c/public/assets/apple-wallet-add.png -------------------------------------------------------------------------------- /public/assets/google-pay-add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eth-pass/nextjs-sample-app/8a6ce37ad05913797260553aeaa834192fb3699c/public/assets/google-pay-add.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eth-pass/nextjs-sample-app/8a6ce37ad05913797260553aeaa834192fb3699c/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | "./pages/**/*.{js,ts,jsx,tsx}", 4 | "./components/**/*.{js,ts,jsx,tsx}", 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | plugins: [require("@tailwindcss/forms")], 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { "*": [ 5 | "*" 6 | ]}, 7 | "target": "es5", 8 | "lib": [ 9 | "dom", 10 | "dom.iterable", 11 | "esnext" 12 | ], 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "strict": false, 16 | "forceConsistentCasingInFileNames": true, 17 | "noEmit": true, 18 | "incremental": true, 19 | "esModuleInterop": true, 20 | "module": "esnext", 21 | "moduleResolution": "node", 22 | "resolveJsonModule": true, 23 | "isolatedModules": true, 24 | "jsx": "preserve" 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx" 30 | ], 31 | "exclude": [ 32 | "node_modules" 33 | ] 34 | } 35 | --------------------------------------------------------------------------------