├── .env.example
├── .github
└── workflows
│ └── publish-to-npm.yaml
├── .gitignore
├── .npmignore
├── .storybook
├── main.ts
└── preview.tsx
├── LICENSE
├── README.md
├── package.json
├── src
├── components
│ ├── NeynarAuthButton
│ │ ├── icons
│ │ │ ├── FarcasterIcon.tsx
│ │ │ ├── PlanetBlackIcon.tsx
│ │ │ └── WarpcastIcon.tsx
│ │ └── index.tsx
│ ├── index.tsx
│ ├── shared
│ │ ├── Box
│ │ │ └── index.tsx
│ │ └── Toast
│ │ │ └── index.ts
│ └── stories
│ │ └── NeynarAuthButton.stories.tsx
├── contexts
│ ├── AuthContextProvider.tsx
│ ├── NeynarContextProvider.tsx
│ └── index.tsx
├── enums.ts
├── hooks
│ ├── index.ts
│ └── use-local-storage-state.ts
├── index.tsx
├── theme
│ └── index.ts
└── types
│ ├── common.ts
│ └── global.d.ts
├── tsconfig.json
├── vite.config.ts
└── yarn.lock
/.env.example:
--------------------------------------------------------------------------------
1 | CLIENT_ID="YOUR_CLIENT_ID"
2 | NEYNAR_API_URL="https://sdk-api.neynar.com"
3 | NEYNAR_LOGIN_URL="https://app.neynar.com/login"
4 |
--------------------------------------------------------------------------------
/.github/workflows/publish-to-npm.yaml:
--------------------------------------------------------------------------------
1 | name: Publish to npm 🚀
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - name: Check out repository
14 | uses: actions/checkout@v2
15 |
16 | - name: Set up Node.js
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version: '20'
20 |
21 | - name: Install dependencies
22 | run: yarn install
23 |
24 | - name: Build
25 | run: yarn build
26 |
27 | - name: Set npm Config
28 | run: npm config set //registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}
29 |
30 | - name: Publish to npm
31 | run: npm publish --access public
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 |
4 | test-sdk.ts
5 | *storybook.log
6 |
7 | .env
8 | .env.local
9 | .env.development
10 | .evn.production
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *.config.js
2 | src/
3 | .github/
4 | rollup.config.mjs
5 | package.json
6 | tsconfig.json
7 |
8 | .storybook
9 |
10 | .npmignore
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from "@storybook/react-vite";
2 |
3 | const config: StorybookConfig = {
4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
5 | addons: [
6 | "@storybook/addon-onboarding",
7 | "@storybook/addon-links",
8 | "@storybook/addon-essentials",
9 | "@chromatic-com/storybook",
10 | "@storybook/addon-interactions",
11 | ],
12 | framework: {
13 | name: "@storybook/react-vite",
14 | options: {},
15 | },
16 | docs: {
17 | autodocs: "tag",
18 | },
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/.storybook/preview.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import type { Preview } from "@storybook/react";
3 | import { NeynarContextProvider } from "../src/contexts/NeynarContextProvider";
4 | import { Theme } from "../src/enums";
5 |
6 | import "../dist/style.css";
7 |
8 | const withNeynarProvider = (Story) => (
9 |
23 |
24 |
25 | );
26 |
27 | const preview: Preview = {
28 | decorators: [withNeynarProvider],
29 | parameters: {
30 | controls: {
31 | matchers: {
32 | color: /(background|color)$/i,
33 | date: /Date$/i,
34 | },
35 | },
36 | },
37 | };
38 |
39 | export default preview;
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Neynar
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @neynar/react
2 |
3 | ## Introduction
4 |
5 | `@neynar/react` is the official Frontend SDK from [Neynar](https://neynar.com/). This SDK includes React components to build Farcaster clients.
6 |
7 | ## Peer dependencies
8 |
9 | Please make sure that the following dependencies are already a part of your project.
10 |
11 | ```json
12 | {
13 | "react": "^18.3.0",
14 | "react-dom": "^18.3.0",
15 | "@pigment-css/react": "^0.0.9"
16 | }
17 | ```
18 |
19 | ## Installation
20 |
21 | - For yarn
22 |
23 | ```bash
24 | yarn add @neynar/react
25 | ```
26 |
27 | - For npm
28 |
29 | ```bash
30 | npm install @neynar/react
31 | ```
32 |
33 | ## Example app
34 |
35 | Check out our [example app](https://github.com/neynarxyz/farcaster-examples/tree/main/wownar-react-sdk) for a demonstration of how to use `@neynar/react`.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@neynar/react",
3 | "version": "0.3.1",
4 | "description": "Farcaster frontend component library powered by Neynar",
5 | "main": "dist/bundle.cjs.js",
6 | "module": "dist/bundle.es.js",
7 | "types": "dist/index.d.ts",
8 | "author": "Neynar",
9 | "license": "MIT",
10 | "scripts": {
11 | "build": "tsc && vite build",
12 | "storybook": "storybook dev -p 6006",
13 | "build-storybook": "storybook build"
14 | },
15 | "eslintConfig": {
16 | "extends": [
17 | "react-app",
18 | "plugin:storybook/recommended"
19 | ]
20 | },
21 | "peerDependencies": {
22 | "react": "^18.3.0",
23 | "react-dom": "^18.3.0",
24 | "@pigment-css/react": "^0.0.9"
25 | },
26 | "devDependencies": {
27 | "@babel/core": "^7.24.4",
28 | "@babel/preset-env": "^7.24.4",
29 | "@babel/preset-react": "^7.24.1",
30 | "@chromatic-com/storybook": "^1.3.3",
31 | "@pigment-css/vite-plugin": "^0.0.9",
32 | "@storybook/addon-essentials": "^8.0.9",
33 | "@storybook/addon-interactions": "^8.0.9",
34 | "@storybook/addon-links": "^8.0.9",
35 | "@storybook/addon-onboarding": "^8.0.9",
36 | "@storybook/blocks": "^8.0.9",
37 | "@storybook/react": "^8.0.9",
38 | "@storybook/react-vite": "^8.0.9",
39 | "@storybook/test": "^8.0.9",
40 | "@types/react": "^18.3.0",
41 | "@types/react-dom": "^18.3.0",
42 | "@vitejs/plugin-react": "^4.2.1",
43 | "axios": "^1.6.8",
44 | "dotenv": "^16.4.5",
45 | "eslint-plugin-storybook": "^0.8.0",
46 | "storybook": "^8.0.9",
47 | "typescript": "^5.4.5",
48 | "vite": "^5.2.10",
49 | "vite-plugin-dts": "^3.9.0",
50 | "vite-tsconfig-paths": "^4.3.2",
51 | "@pigment-css/react": "^0.0.9"
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/icons/FarcasterIcon.tsx:
--------------------------------------------------------------------------------
1 | export const FarcasterIcon = () => {
2 | return (
3 |
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/icons/PlanetBlackIcon.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const PlanetBlackIcon = () => (
4 |
38 | );
39 |
40 | export default PlanetBlackIcon;
41 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/icons/WarpcastIcon.tsx:
--------------------------------------------------------------------------------
1 | export const WarpcastIcon = () => {
2 | return (
3 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/src/components/NeynarAuthButton/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState, useRef } from "react";
2 | import { styled } from "@pigment-css/react";
3 | import PlanetBlackIcon from "./icons/PlanetBlackIcon";
4 | import { useNeynarContext } from "../../contexts";
5 | import { useAuth } from "../../contexts/AuthContextProvider";
6 | import { useLocalStorage } from "../../hooks";
7 | import { LocalStorageKeys } from "../../hooks/use-local-storage-state";
8 | import { INeynarAuthenticatedUser } from "../../types/common";
9 | import { SIWN_variant } from "../../enums";
10 | import { FarcasterIcon } from "./icons/FarcasterIcon";
11 | import { WarpcastIcon } from "./icons/WarpcastIcon";
12 |
13 | interface ButtonProps extends React.ButtonHTMLAttributes {
14 | label?: string;
15 | icon?: React.ReactNode;
16 | variant?: SIWN_variant;
17 | modalStyle?: React.CSSProperties;
18 | modalButtonStyle?: React.CSSProperties;
19 | }
20 |
21 | const Img = styled.img({
22 | width: "20px",
23 | height: "20px",
24 | borderRadius: "50%",
25 | });
26 |
27 | const Button = styled.button((props) => ({
28 | backgroundColor: "#ffffff",
29 | border: "none",
30 | color: "#000000",
31 | padding: "15px",
32 | fontSize: "15px",
33 | fontWeight: "600",
34 | lineHeight: "18.9px",
35 | borderRadius: "100px",
36 | display: "flex",
37 | alignItems: "center",
38 | justifyContent: "center",
39 | cursor: "pointer",
40 | textDecoration: "none",
41 | boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
42 | transition: "background-color 0.3s",
43 | }));
44 |
45 | const Modal = styled.div((props) => ({
46 | position: "fixed",
47 | top: "50%",
48 | left: "50%",
49 | transform: "translate(-50%, -50%)",
50 | width: "300px",
51 | padding: "20px",
52 | display: "flex",
53 | flexDirection: "column",
54 | justifyContent: "space-around",
55 | rowGap: "20px",
56 | alignItems: "center",
57 | backgroundColor: "#fff",
58 | borderRadius: "15px",
59 | boxShadow: "0 4px 8px rgba(0, 0, 0, 0.1)",
60 | zIndex: "1000",
61 | fontFamily: props.theme.typography.fonts.base,
62 | fontSize: props.theme.typography.fontSizes.medium,
63 | "> img": {
64 | width: "80px",
65 | height: "80px",
66 | borderRadius: "50%",
67 | },
68 | "> span": {
69 | color: "#000",
70 | fontWeight: "bold",
71 | },
72 | }));
73 |
74 | const ModalButton = styled.button({
75 | width: "100%",
76 | padding: "10px 0",
77 | backgroundColor: "#ffffff",
78 | color: "#000000",
79 | border: "1px solid #e0e0e0",
80 | borderRadius: "8px",
81 | fontWeight: "bold",
82 | cursor: "pointer",
83 | transition: "background-color 0.2s",
84 | "&:hover": {
85 | boxShadow: "0 2px 3px rgba(0, 0, 0, 0.1)",
86 | },
87 | });
88 |
89 | export const NeynarAuthButton: React.FC = ({
90 | children,
91 | label = "Sign in with Neynar",
92 | icon = ,
93 | variant = SIWN_variant.NEYNAR,
94 | modalStyle = {},
95 | modalButtonStyle = {},
96 | ...rest
97 | }) => {
98 | const { client_id, user, isAuthenticated } = useNeynarContext();
99 | const { setIsAuthenticated, setUser, onAuthSuccess, onSignout } = useAuth();
100 | const [_, setNeynarAuthenticatedUser, removeNeynarAuthenticatedUser] =
101 | useLocalStorage(
102 | LocalStorageKeys.NEYNAR_AUTHENTICATED_USER
103 | );
104 | const [showModal, setShowModal] = useState(false);
105 |
106 | // Using useRef to store the authWindow reference
107 | const authWindowRef = useRef(null);
108 | const neynarLoginUrl = `${process.env.NEYNAR_LOGIN_URL ?? "https://app.neynar.com/login"}?client_id=${client_id}`;
109 | const authOrigin = new URL(neynarLoginUrl).origin;
110 |
111 | const modalRef = useRef(null);
112 |
113 | const handleMessage = useCallback(
114 | async (event: MessageEvent) => {
115 | if (
116 | event.origin === authOrigin &&
117 | event.data &&
118 | event.data.is_authenticated
119 | ) {
120 | setIsAuthenticated(true);
121 | authWindowRef.current?.close();
122 | window.removeEventListener("message", handleMessage); // Remove listener here
123 | const _user = {
124 | signer_uuid: event.data.signer_uuid,
125 | ...event.data.user,
126 | };
127 | setNeynarAuthenticatedUser(_user);
128 | setUser(_user);
129 | onAuthSuccess({ user: _user });
130 | }
131 | },
132 | [client_id, setIsAuthenticated]
133 | );
134 |
135 | const handleSignIn = useCallback(() => {
136 | const width = 600,
137 | height = 700;
138 | const left = window.screen.width / 2 - width / 2;
139 | const top = window.screen.height / 2 - height / 2;
140 | const windowFeatures = `width=${width},height=${height},top=${top},left=${left}`;
141 |
142 | authWindowRef.current = window.open(
143 | neynarLoginUrl,
144 | "_blank",
145 | windowFeatures
146 | );
147 |
148 | if (!authWindowRef.current) {
149 | console.error(
150 | "Failed to open the authentication window. Please check your pop-up blocker settings."
151 | );
152 | return;
153 | }
154 |
155 | window.addEventListener("message", handleMessage, false);
156 | }, [client_id, handleMessage]);
157 |
158 | const handleSignOut = () => {
159 | if (user) {
160 | const _user = user;
161 | removeNeynarAuthenticatedUser();
162 | setIsAuthenticated(false);
163 | closeModal();
164 | onSignout(_user);
165 | }
166 | };
167 |
168 | const openModal = () => setShowModal(true);
169 | const closeModal = () => setShowModal(false);
170 |
171 | useEffect(() => {
172 | return () => {
173 | window.removeEventListener("message", handleMessage); // Cleanup function to remove listener
174 | };
175 | }, [handleMessage]);
176 |
177 | const handleOutsideClick = useCallback((event: any) => {
178 | if (modalRef.current && !modalRef.current.contains(event.target)) {
179 | closeModal();
180 | }
181 | }, []);
182 |
183 | useEffect(() => {
184 | if (showModal) {
185 | document.addEventListener("mousedown", handleOutsideClick);
186 | } else {
187 | document.removeEventListener("mousedown", handleOutsideClick);
188 | }
189 |
190 | return () => {
191 | document.removeEventListener("mousedown", handleOutsideClick);
192 | };
193 | }, [showModal, handleOutsideClick]);
194 |
195 | const getLabel = () => {
196 | switch (variant) {
197 | case SIWN_variant.FARCASTER:
198 | return "Sign in with Farcaster";
199 | case SIWN_variant.NEYNAR:
200 | return "Sign in with Neynar";
201 | case SIWN_variant.WARPCAST:
202 | return "Sign in with Warpcast";
203 | default:
204 | return "Sign in with Neynar";
205 | }
206 | };
207 |
208 | const getIcon = () => {
209 | switch (variant) {
210 | case SIWN_variant.FARCASTER:
211 | return ;
212 | case SIWN_variant.NEYNAR:
213 | return ;
214 | case SIWN_variant.WARPCAST:
215 | return ;
216 | default:
217 | return ;
218 | }
219 | };
220 |
221 | return (
222 | <>
223 | {showModal && (
224 |
225 |
226 | @{user?.username}
227 |
228 | Sign out
229 |
230 |
231 | )}
232 |
253 | >
254 | );
255 | };
256 |
--------------------------------------------------------------------------------
/src/components/index.tsx:
--------------------------------------------------------------------------------
1 | export { NeynarAuthButton } from "./NeynarAuthButton";
2 |
--------------------------------------------------------------------------------
/src/components/shared/Box/index.tsx:
--------------------------------------------------------------------------------
1 | import { HTMLAttributes } from "react";
2 | import { styled } from "@pigment-css/react";
3 |
4 | interface BoxProps extends HTMLAttributes {
5 | alignItems?: "center" | "flex-start" | "flex-end";
6 | justifyContent?: "center" | "flex-start" | "flex-end" | 'space-between' | 'space-around' | 'space-evenly';
7 | flexGrow?: number;
8 | flexShrink?: number;
9 | };
10 |
11 | export const Box = styled.div({
12 | display: "flex",
13 | alignItems: props => props.alignItems || "flex-start",
14 | justifyContent: props => props.justifyContent || "flex-start",
15 | flexGrow: props => props.flexGrow || 'initial',
16 | flexShrink: props => props.flexShrink || 'initial',
17 | });
18 |
19 | export const VBox = styled(Box)({
20 | flexDirection: "column",
21 | });
22 |
23 | export const HBox = styled(Box)({
24 | flexDirection: "row",
25 | });
26 |
--------------------------------------------------------------------------------
/src/components/shared/Toast/index.ts:
--------------------------------------------------------------------------------
1 | import { styled, keyframes } from "@pigment-css/react";
2 |
3 | const fadeInOut = keyframes`
4 | 0% {
5 | opacity: 0;
6 | }
7 | 10% {
8 | opacity: 1;
9 | }
10 | 90% {
11 | opacity: 1;
12 | }
13 | 100% {
14 | opacity: 1;
15 | }
16 | `;
17 |
18 | // Styled component for the toast container
19 | export const ToastContainer = styled.div`
20 | position: fixed;
21 | bottom: 20px;
22 | right: 20px;
23 | z-index: 9999;
24 | `;
25 |
26 | export const ToastItem = styled("div")<{ type: string }>((props) => ({
27 | padding: "10px 20px",
28 | marginBottom: "10px",
29 | borderRadius: "5px",
30 | color: "#fff",
31 | animation: `${fadeInOut} 4s ease-out`,
32 | fontFamily: props.theme.typography.fonts.base,
33 | fontSize: props.theme.typography.fontSizes.medium,
34 | variants: [
35 | {
36 | props: { type: "success" },
37 | style: {
38 | backgroundColor: "#32cd32",
39 | },
40 | },
41 | {
42 | props: { type: "error" },
43 | style: {
44 | backgroundColor: "#ff6347",
45 | },
46 | },
47 | {
48 | props: { type: "warning" },
49 | style: {
50 | backgroundColor: "#ffa500",
51 | },
52 | },
53 | {
54 | props: { type: "info" },
55 | style: {
56 | backgroundColor: "#3498db",
57 | },
58 | },
59 | ],
60 | }));
61 |
62 | export enum ToastType {
63 | Success = "success",
64 | Error = "error",
65 | Warning = "warning",
66 | Info = "info",
67 | }
68 |
--------------------------------------------------------------------------------
/src/components/stories/NeynarAuthButton.stories.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Meta, StoryFn } from "@storybook/react";
3 | import { NeynarAuthButton } from "../NeynarAuthButton/index";
4 | import { SIWN_variant } from "../../enums";
5 |
6 | export default {
7 | title: "NeynarAuthButton",
8 | component: NeynarAuthButton,
9 | } as Meta;
10 |
11 | const Template: StoryFn = (args) => ;
12 |
13 | export const Primary = Template.bind({});
14 | Primary.args = {
15 | primary: true,
16 | label: "Sign in with Neynar",
17 | variant: SIWN_variant.FARCASTER,
18 | };
19 |
--------------------------------------------------------------------------------
/src/contexts/AuthContextProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useContext,
4 | useState,
5 | ReactNode,
6 | useMemo,
7 | useEffect,
8 | } from "react";
9 | import { INeynarAuthenticatedUser, IUser, SetState } from "../types/common";
10 | import { useLocalStorage } from "../hooks";
11 | import { LocalStorageKeys } from "../hooks/use-local-storage-state";
12 | import { useNeynarContext } from "./NeynarContextProvider";
13 |
14 | interface IAuthContext {
15 | isAuthenticated: boolean;
16 | setIsAuthenticated: SetState;
17 | user: INeynarAuthenticatedUser | null;
18 | setUser: SetState;
19 | onAuthSuccess: (params: { user: IUser }) => void;
20 | onSignout: (user: IUser | undefined) => void;
21 | }
22 |
23 | const AuthContext = createContext(undefined);
24 |
25 | export interface AuthContextProviderProps {
26 | children: ReactNode;
27 | _setIsAuthenticated: (_isAuthenticated: boolean) => void;
28 | _setUser: (_user: INeynarAuthenticatedUser | null) => void;
29 | _onAuthSuccess?: (params: { user: IUser }) => void;
30 | _onSignout?: (user: IUser | undefined) => void;
31 | }
32 |
33 | export const AuthContextProvider: React.FC = ({
34 | children,
35 | _setIsAuthenticated,
36 | _setUser,
37 | _onAuthSuccess,
38 | _onSignout,
39 | }) => {
40 | const { isAuthenticated: _isAuthenticated } = useNeynarContext();
41 |
42 | const [isAuthenticated, setIsAuthenticated] = useState(false);
43 | const [user, setUser] = useState(null);
44 | const [neynarAuthenticatedUser] = useLocalStorage(
45 | LocalStorageKeys.NEYNAR_AUTHENTICATED_USER
46 | );
47 |
48 | useEffect(() => {
49 | _setIsAuthenticated(isAuthenticated);
50 | }, [isAuthenticated]);
51 |
52 | useEffect(() => {
53 | setIsAuthenticated(_isAuthenticated);
54 | }, [_isAuthenticated]);
55 |
56 | useEffect(() => {
57 | if (neynarAuthenticatedUser) {
58 | setUser(neynarAuthenticatedUser);
59 | setIsAuthenticated(true);
60 | } else {
61 | setUser(null);
62 | setIsAuthenticated(false);
63 | }
64 | }, []);
65 |
66 | useEffect(() => {
67 | _setUser(user);
68 | }, [user]);
69 |
70 | const onAuthSuccess = (params: { user: IUser }) => {
71 | _onAuthSuccess && _onAuthSuccess(params);
72 | };
73 |
74 | const onSignout = (user: IUser | undefined) => {
75 | _onSignout && _onSignout(user);
76 | };
77 |
78 | const value = useMemo(
79 | () => ({
80 | isAuthenticated,
81 | user,
82 | setIsAuthenticated,
83 | setUser,
84 | onAuthSuccess,
85 | onSignout,
86 | }),
87 | [isAuthenticated, user]
88 | );
89 |
90 | return {children};
91 | };
92 |
93 | export const useAuth = () => {
94 | const context = useContext(AuthContext);
95 | if (!context) {
96 | throw new Error("useAuth must be used within a AuthContextProvider");
97 | }
98 | return context;
99 | };
100 |
--------------------------------------------------------------------------------
/src/contexts/NeynarContextProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, {
2 | createContext,
3 | useContext,
4 | useState,
5 | ReactNode,
6 | useMemo,
7 | useEffect,
8 | } from "react";
9 | import { Theme } from "../enums";
10 | import { INeynarAuthenticatedUser, IUser, SetState } from "../types/common";
11 | import { AuthContextProvider } from "./AuthContextProvider";
12 | import {
13 | ToastContainer,
14 | ToastItem,
15 | ToastType,
16 | } from "../components/shared/Toast";
17 | import { LocalStorageKeys } from "../hooks/use-local-storage-state";
18 |
19 | interface INeynarContext {
20 | client_id: string;
21 | theme: Theme;
22 | setTheme: SetState;
23 | isAuthenticated: boolean;
24 | showToast: (type: ToastType, message: string) => void;
25 | user: INeynarAuthenticatedUser | null;
26 | logoutUser: () => void;
27 | }
28 |
29 | const NeynarContext = createContext(undefined);
30 |
31 | export interface NeynarContextProviderProps {
32 | children: ReactNode;
33 | settings: {
34 | clientId: string;
35 | defaultTheme?: Theme;
36 | eventsCallbacks?: {
37 | onAuthSuccess?: (params: { user: IUser }) => void;
38 | onSignout?: (user: IUser | undefined) => void;
39 | };
40 | };
41 | }
42 |
43 | export const NeynarContextProvider: React.FC = ({
44 | children,
45 | settings: { clientId, defaultTheme = Theme.Light, eventsCallbacks },
46 | }) => {
47 | const [client_id] = useState(clientId);
48 | const [isAuthenticated, setIsAuthenticated] = useState(false);
49 | const [theme, setTheme] = useState(defaultTheme);
50 | const [toasts, setToasts] = useState<{ type: string; message: string }[]>([]);
51 | const [user, setUser] = useState(null);
52 |
53 | const showToast = (type: ToastType, message: string) => {
54 | const newToast = { type, message };
55 | setToasts((prevToasts) => [...prevToasts, newToast]);
56 | setTimeout(() => removeToast(newToast), 5000); // Remove toast after 5 seconds
57 | };
58 |
59 | const removeToast = (toastToRemove: { type: string; message: string }) => {
60 | setToasts((prevToasts) =>
61 | prevToasts.filter((toast) => toast !== toastToRemove)
62 | );
63 | };
64 |
65 | useEffect(() => {
66 | const root = document.querySelector(":root");
67 | if (root) {
68 | if (theme === "light") {
69 | root.classList.add("theme-light");
70 | // root.classList.remove("theme-dark");
71 | }
72 | // else {
73 | // root.classList.add("theme-dark");
74 | // root.classList.remove("theme-light");
75 | // }
76 | }
77 | }, [theme]);
78 |
79 | const _setIsAuthenticated = (_isAuthenticated: boolean) => {
80 | setIsAuthenticated(_isAuthenticated);
81 | };
82 |
83 | const _setUser = (_user: INeynarAuthenticatedUser | null) => {
84 | setUser(_user);
85 | };
86 |
87 | const logoutUser = () => {
88 | if (user) {
89 | const { signer_uuid, ...rest } = user;
90 | setUser(null);
91 | setIsAuthenticated(false);
92 | localStorage.removeItem(LocalStorageKeys.NEYNAR_AUTHENTICATED_USER);
93 | if (eventsCallbacks?.onSignout) {
94 | eventsCallbacks.onSignout(rest);
95 | }
96 | }
97 | };
98 |
99 | const value = useMemo(
100 | () => ({
101 | client_id,
102 | theme,
103 | isAuthenticated,
104 | user,
105 | setTheme,
106 | showToast,
107 | logoutUser,
108 | }),
109 | [client_id, theme, isAuthenticated, user, setTheme, showToast, logoutUser]
110 | );
111 |
112 | return (
113 |
114 |
122 | {children}
123 |
124 | {toasts.map((toast, index) => (
125 |
126 | {toast.message}
127 |
128 | ))}
129 |
130 |
131 |
132 | );
133 | };
134 |
135 | export const useNeynarContext = () => {
136 | const context = useContext(NeynarContext);
137 | if (!context) {
138 | throw new Error(
139 | "useNeynarContext must be used within a NeynarContextProvider"
140 | );
141 | }
142 | return context;
143 | };
144 |
--------------------------------------------------------------------------------
/src/contexts/index.tsx:
--------------------------------------------------------------------------------
1 | export {
2 | NeynarContextProvider,
3 | useNeynarContext,
4 | } from "./NeynarContextProvider";
5 |
--------------------------------------------------------------------------------
/src/enums.ts:
--------------------------------------------------------------------------------
1 | export enum Theme {
2 | Light = "light",
3 | }
4 |
5 | export enum SIWN_variant {
6 | FARCASTER = "farcaster",
7 | NEYNAR = "neynar",
8 | WARPCAST = "warpcast",
9 | }
10 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export { useLocalStorage } from "./use-local-storage-state";
2 |
--------------------------------------------------------------------------------
/src/hooks/use-local-storage-state.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | type SerializeFunction = (value: T) => string;
4 | type DeserializeFunction = (value: string) => T;
5 |
6 | interface UseLocalStorageStateOptions {
7 | serialize?: SerializeFunction;
8 | deserialize?: DeserializeFunction;
9 | }
10 |
11 | export function useLocalStorage(
12 | key: string,
13 | defaultValue: T = "" as T,
14 | {
15 | serialize = JSON.stringify,
16 | deserialize = JSON.parse,
17 | }: UseLocalStorageStateOptions = {}
18 | ): [T, (value: T) => void, () => void] {
19 | const [storedValue, setStoredValue] = useState(() => {
20 | if (typeof window === "undefined") {
21 | return defaultValue;
22 | }
23 | try {
24 | const item = window.localStorage.getItem(key);
25 | return item ? deserialize(item) : defaultValue;
26 | } catch (error) {
27 | console.error("Error reading from localStorage", error);
28 | return defaultValue;
29 | }
30 | });
31 |
32 | const setValue = (value: T) => {
33 | try {
34 | const valueToStore =
35 | value instanceof Function ? value(storedValue) : value;
36 | setStoredValue(valueToStore);
37 | if (typeof window !== "undefined") {
38 | window.localStorage.setItem(key, serialize(valueToStore));
39 | }
40 | } catch (error) {
41 | console.error("Error writing to localStorage", error);
42 | }
43 | };
44 |
45 | const removeItem = () => {
46 | try {
47 | window.localStorage.removeItem(key);
48 | setStoredValue(defaultValue);
49 | } catch (error) {
50 | console.error("Error removing from localStorage", error);
51 | }
52 | };
53 |
54 | return [storedValue, setValue, removeItem];
55 | }
56 |
57 | export enum LocalStorageKeys {
58 | NEYNAR_AUTHENTICATED_USER = "neynar_authenticated_user",
59 | }
60 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import "@pigment-css/react/styles.css";
2 |
3 | // Components
4 | export * from "./components";
5 |
6 | // Contexts
7 | export * from "./contexts";
8 |
9 | // Hooks
10 | export * from "./hooks";
11 |
12 | // Enums
13 | export { Theme, SIWN_variant } from "./enums";
14 |
--------------------------------------------------------------------------------
/src/theme/index.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from "@pigment-css/vite-plugin";
2 |
3 | export const theme = extendTheme({
4 | colorSchemes: {
5 | light: {
6 | palette: {
7 | background: "#FFFFFF",
8 | text: "#15111D",
9 | primary: "#8A63D2",
10 | border: "#2E3031",
11 | lightGrey: "#A0A3AD",
12 | },
13 | },
14 | dark: {
15 | palette: {
16 | background: "240 10% 3.9%",
17 | foreground: "0 0% 80%",
18 | primary: "0 0% 98%",
19 | border: "240 3.7% 15.9%",
20 | },
21 | },
22 | },
23 | typography: {
24 | fonts: {
25 | base: "Sora, sans-serif",
26 | },
27 | fontWeights: {
28 | regular: 400,
29 | bold: 700,
30 | },
31 | fontSizes: {
32 | large: "20px",
33 | medium: "15px",
34 | small: "12px",
35 | },
36 | },
37 | getSelector: (colorScheme) =>
38 | colorScheme ? `.theme-${colorScheme}` : ":root",
39 | });
40 |
--------------------------------------------------------------------------------
/src/types/common.ts:
--------------------------------------------------------------------------------
1 | export type SetState = React.Dispatch>;
2 |
3 | export interface INeynarAuthenticatedUser {
4 | signer_uuid: string;
5 | object: "user";
6 | fid: number;
7 | username: string;
8 | display_name?: string;
9 | custody_address: string;
10 | pfp_url?: string;
11 | profile: {
12 | bio: {
13 | text: string;
14 | mentioned_profiles?: string[];
15 | };
16 | };
17 | follower_count: number;
18 | following_count: number;
19 | verifications?: string[];
20 | verified_addresses: {
21 | eth_addresses?: string[];
22 | sol_addresses?: string[];
23 | };
24 | active_status: "active" | "inactive";
25 | power_badge: boolean;
26 | viewer_context?: {
27 | following: boolean;
28 | followed_by: boolean;
29 | };
30 | }
31 |
32 | export interface IUser {
33 | object: "user";
34 | fid: number;
35 | username: string;
36 | display_name?: string;
37 | custody_address: string;
38 | pfp_url?: string;
39 | profile: {
40 | bio: {
41 | text: string;
42 | mentioned_profiles?: string[];
43 | };
44 | };
45 | follower_count: number;
46 | following_count: number;
47 | verifications?: string[];
48 | verified_addresses: {
49 | eth_addresses?: string[];
50 | sol_addresses?: string[];
51 | };
52 | active_status: "active" | "inactive";
53 | power_badge: boolean;
54 | viewer_context?: {
55 | following: boolean;
56 | followed_by: boolean;
57 | };
58 | }
59 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | import type {} from "@pigment-css/react/theme";
2 | import type { ExtendTheme } from "@pigment-css/react";
3 |
4 | declare module "@pigment-css/react/theme" {
5 | export interface ThemeArgs {
6 | theme: ExtendTheme<{
7 | colorScheme: "light" | "dark";
8 | tokens: {
9 | palette: {
10 | background: string;
11 | foreground: string;
12 | primary: string;
13 | primaryForeground: string;
14 | border: string;
15 | };
16 | };
17 | }>;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
6 | "allowJs": false,
7 | "skipLibCheck": true,
8 | "esModuleInterop": false,
9 | "allowSyntheticDefaultImports": true,
10 | "strict": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src/**/*"],
20 | "exclude": ["node_modules", "dist", "src/**/*.stories.tsx"]
21 | }
22 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import { pigment } from "@pigment-css/vite-plugin";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 | import dts from "vite-plugin-dts";
6 | import { theme } from "./src/theme/index";
7 | import { config } from "dotenv";
8 |
9 | config({ path: ".env.local" });
10 |
11 | // https://vitejs.dev/config/
12 | export default defineConfig({
13 | plugins: [
14 | pigment({
15 | theme,
16 | }),
17 | react(),
18 | tsconfigPaths(),
19 | dts({
20 | insertTypesEntry: true, // This option adds an entry for the type definitions in your package.json
21 | }),
22 | ],
23 | define: {
24 | "process.env": process.env,
25 | },
26 | build: {
27 | outDir: "dist",
28 | lib: {
29 | entry: "src/index.tsx",
30 | formats: ["es", "cjs"],
31 | fileName: (format) => `bundle.${format}.js`,
32 | },
33 | rollupOptions: {
34 | external: ["react", "react-dom"],
35 | output: {
36 | globals: {
37 | react: "React",
38 | "react-dom": "ReactDOM",
39 | },
40 | },
41 | },
42 | },
43 | });
44 |
--------------------------------------------------------------------------------