├── .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 | 
32 |
33 | ### Scanning a pass
34 |
35 | Scan passes to verify ownership and view the data you encoded in the barcode.
36 |
37 | 
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 |
32 |
33 |
34 |
35 |
{`Scan QR code using your ${
36 | platform === Platform.GOOGLE ? "Android" : "Apple"
37 | } device`}
38 |
39 |
40 |
41 |
42 | Or tap below to download directly on your mobile device.
43 |
44 |
45 | {platform && platform === Platform.APPLE ? (
46 |
47 |
53 |
54 | ) : (
55 | platform && (
56 |
57 |
63 |
64 | )
65 | )}
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/components/LoadingIndicator.tsx:
--------------------------------------------------------------------------------
1 | export default function LoadingIndicator({ asPage = false }) {
2 | const Loader = () => (
3 |
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 |
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 |
37 | ),
38 | },
39 | {
40 | name: "Crossmint",
41 | route: "/crossmint",
42 | description: "Using Crossmint custodial wallet",
43 | icon: (
44 |
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 |
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 |
154 |
155 |
156 |
160 | Pass Invalid
161 |
162 |
163 |
164 |
165 | );
166 | }
167 | if (scanResult.lastScannedAt) {
168 | return (
169 |
170 |
171 |
172 |
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 |
195 |
196 |
197 |
201 | Valid Pass
202 |
203 |
204 |
205 | );
206 | }
207 | };
208 |
209 | return (
210 | <>
211 |
212 |
218 |
219 |
220 |
227 |
234 |
235 |
236 |
237 |
238 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------