├── .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 | 10 | 11 | 15 | 19 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/NeynarAuthButton/icons/PlanetBlackIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const PlanetBlackIcon = () => ( 4 | 11 | 17 | 23 | 29 | 37 | 38 | ); 39 | 40 | export default PlanetBlackIcon; 41 | -------------------------------------------------------------------------------- /src/components/NeynarAuthButton/icons/WarpcastIcon.tsx: -------------------------------------------------------------------------------- 1 | export const WarpcastIcon = () => { 2 | return ( 3 | 10 | 11 | 15 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 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 | {user?.username} 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 | --------------------------------------------------------------------------------