├── .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 │ ├── NeynarProfileCard │ │ ├── components │ │ │ └── ProfileCard.tsx │ │ ├── hooks │ │ │ └── useLinkifyBio.tsx │ │ ├── icons │ │ │ └── WarpcastPowerBadge.tsx │ │ └── index.tsx │ ├── index.tsx │ ├── shared │ │ ├── Avatar │ │ │ └── index.tsx │ │ ├── Box │ │ │ └── index.tsx │ │ ├── ButtonOutline │ │ │ └── index.tsx │ │ ├── ButtonPrimary │ │ │ └── index.tsx │ │ └── Toast │ │ │ └── index.ts │ └── stories │ │ ├── NeynarAuthButton.stories.tsx │ │ └── NeynarProfileCard.stories.tsx ├── constants.ts ├── 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 └── utils │ └── formatUtils.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | CLIENT_ID="YOUR_CLIENT_ID" 2 | NEYNAR_LOGIN_URL="https://app.neynar.com/login" 3 | -------------------------------------------------------------------------------- /.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 | '@storybook/addon-themes', 12 | ], 13 | framework: { 14 | name: "@storybook/react-vite", 15 | options: {}, 16 | }, 17 | docs: { 18 | autodocs: "tag", 19 | }, 20 | }; 21 | export default config; 22 | -------------------------------------------------------------------------------- /.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import type { Preview, Decorator } from "@storybook/react"; 3 | import { withThemeByClassName } from "@storybook/addon-themes"; 4 | import { NeynarContextProvider } from "../src/contexts/NeynarContextProvider"; 5 | import { Theme } from "../src/enums"; 6 | 7 | import "../dist/style.css"; 8 | 9 | const themeDecorator = withThemeByClassName({ 10 | defaultTheme: Theme.Light, 11 | themes: { 12 | light: "theme-light", 13 | dark: "theme-dark", 14 | }, 15 | }); 16 | 17 | const withNeynarProvider: Decorator = (Story, context) => { 18 | const theme = context.globals.theme || Theme.Light; 19 | 20 | return ( 21 | 35 | 36 | 37 | ); 38 | }; 39 | 40 | const preview: Preview = { 41 | decorators: [themeDecorator, withNeynarProvider], 42 | parameters: { 43 | controls: { 44 | matchers: { 45 | color: /(background|color)$/i, 46 | date: /Date$/i, 47 | }, 48 | }, 49 | }, 50 | }; 51 | 52 | export default preview; 53 | -------------------------------------------------------------------------------- /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.4.2", 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 | "@pigment-css/react": "^0.0.9", 23 | "react": "^18.3.0", 24 | "react-dom": "^18.3.0" 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/react": "^0.0.9", 32 | "@pigment-css/vite-plugin": "^0.0.9", 33 | "@storybook/addon-essentials": "^8.0.9", 34 | "@storybook/addon-interactions": "^8.0.9", 35 | "@storybook/addon-links": "^8.0.9", 36 | "@storybook/addon-onboarding": "^8.0.9", 37 | "@storybook/addon-themes": "^8.1.2", 38 | "@storybook/blocks": "^8.0.9", 39 | "@storybook/react": "^8.0.9", 40 | "@storybook/react-vite": "^8.0.9", 41 | "@storybook/test": "^8.0.9", 42 | "@types/react": "^18.3.0", 43 | "@types/react-dom": "^18.3.0", 44 | "@vitejs/plugin-react": "^4.2.1", 45 | "axios": "^1.6.8", 46 | "dotenv": "^16.4.5", 47 | "eslint-plugin-storybook": "^0.8.0", 48 | "storybook": "^8.0.9", 49 | "typescript": "^5.4.5", 50 | "vite": "^5.2.10", 51 | "vite-plugin-dts": "^3.9.0", 52 | "vite-tsconfig-paths": "^4.3.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /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 | const { signer_uuid, ...rest } = _user; 165 | onSignout(rest); 166 | } 167 | }; 168 | 169 | const openModal = () => setShowModal(true); 170 | const closeModal = () => setShowModal(false); 171 | 172 | useEffect(() => { 173 | return () => { 174 | window.removeEventListener("message", handleMessage); // Cleanup function to remove listener 175 | }; 176 | }, [handleMessage]); 177 | 178 | const handleOutsideClick = useCallback((event: any) => { 179 | if (modalRef.current && !modalRef.current.contains(event.target)) { 180 | closeModal(); 181 | } 182 | }, []); 183 | 184 | useEffect(() => { 185 | if (showModal) { 186 | document.addEventListener("mousedown", handleOutsideClick); 187 | } else { 188 | document.removeEventListener("mousedown", handleOutsideClick); 189 | } 190 | 191 | return () => { 192 | document.removeEventListener("mousedown", handleOutsideClick); 193 | }; 194 | }, [showModal, handleOutsideClick]); 195 | 196 | const getLabel = () => { 197 | switch (variant) { 198 | case SIWN_variant.FARCASTER: 199 | return "Sign in with Farcaster"; 200 | case SIWN_variant.NEYNAR: 201 | return "Sign in with Neynar"; 202 | case SIWN_variant.WARPCAST: 203 | return "Sign in with Warpcast"; 204 | default: 205 | return "Sign in with Neynar"; 206 | } 207 | }; 208 | 209 | const getIcon = () => { 210 | switch (variant) { 211 | case SIWN_variant.FARCASTER: 212 | return ; 213 | case SIWN_variant.NEYNAR: 214 | return ; 215 | case SIWN_variant.WARPCAST: 216 | return ; 217 | default: 218 | return ; 219 | } 220 | }; 221 | 222 | return ( 223 | <> 224 | {showModal && ( 225 | 226 | {user?.username} 227 | @{user?.username} 228 | 229 | Sign out 230 | 231 | 232 | )} 233 | 254 | 255 | ); 256 | }; 257 | -------------------------------------------------------------------------------- /src/components/NeynarProfileCard/components/ProfileCard.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, memo } from "react"; 2 | import { styled } from "@pigment-css/react"; 3 | import { Box, HBox, VBox } from "../../shared/Box"; 4 | import { formatToReadableNumber } from "../../../utils/formatUtils"; 5 | import { useLinkifyBio } from "../hooks/useLinkifyBio"; 6 | import { WarpcastPowerBadge } from "../icons/WarpcastPowerBadge"; 7 | import ButtonOutline from "../../shared/ButtonOutline"; 8 | import ButtonPrimary from "../../shared/ButtonPrimary"; 9 | import Avatar from "../../shared/Avatar"; 10 | 11 | const StyledProfileCard = styled.div(({ theme }) => ({ 12 | display: "flex", 13 | flexDirection: "column", 14 | width: "100%", 15 | maxWidth: "608px", 16 | borderWidth: "1px", 17 | borderStyle: "solid", 18 | borderColor: theme.vars.palette.border, 19 | borderRadius: "15px", 20 | padding: "30px", 21 | color: theme.vars.palette.text, 22 | fontFamily: theme.typography.fonts.base, 23 | fontSize: theme.typography.fontSizes.medium, 24 | backgroundColor: theme.vars.palette.background, 25 | })); 26 | 27 | const Main = styled.div(() => ({ 28 | display: "flex", 29 | flexDirection: "column", 30 | justifyContent: "space-between", 31 | flex: 1, 32 | })); 33 | 34 | const Username = styled.div(({ theme }) => ({ 35 | color: theme.vars.palette.textMuted, 36 | })); 37 | 38 | const UsernameTitle = styled.div(({ theme }) => ({ 39 | fontSize: theme.typography.fontSizes.large, 40 | fontWeight: theme.typography.fontWeights.bold, 41 | })); 42 | 43 | const ProfileMetaCell = styled.div(({ theme }) => ({ 44 | color: theme.vars.palette.textMuted, 45 | "> strong": { 46 | color: theme.vars.palette.text, 47 | }, 48 | "& + &": { 49 | marginLeft: "15px", 50 | }, 51 | })); 52 | 53 | const Tag = styled.div(({ theme }) => ({ 54 | borderWidth: "1px", 55 | borderStyle: "solid", 56 | borderColor: theme.vars.palette.border, 57 | borderRadius: "5px", 58 | padding: "3px 6px", 59 | marginTop: "3px", 60 | marginLeft: "5px", 61 | backgroundColor: "transparent", 62 | fontSize: theme.typography.fontSizes.small, 63 | color: theme.vars.palette.textMuted, 64 | lineHeight: 1, 65 | })); 66 | 67 | export type ProfileCardProps = { 68 | username: string; 69 | displayName: string; 70 | avatarImgUrl: string; 71 | bio: string; 72 | followers: number; 73 | following: number; 74 | hasPowerBadge: boolean; 75 | isFollowing?: boolean; 76 | isOwnProfile?: boolean; 77 | onCast?: () => void; 78 | }; 79 | 80 | export const ProfileCard = memo( 81 | ({ 82 | username, 83 | displayName, 84 | avatarImgUrl, 85 | bio, 86 | followers, 87 | following, 88 | hasPowerBadge, 89 | isFollowing, 90 | isOwnProfile, 91 | onCast, 92 | }: ProfileCardProps) => { 93 | const linkifiedBio = useLinkifyBio(bio); 94 | 95 | const formattedFollowingCount = useMemo( 96 | () => formatToReadableNumber(following), 97 | [following] 98 | ); 99 | 100 | const formattedFollowersCount = useMemo( 101 | () => formatToReadableNumber(followers), 102 | [followers] 103 | ); 104 | 105 | const handleEditProfile = () => { 106 | window.open("https://warpcast.com/~/settings", "_blank"); 107 | }; 108 | 109 | return ( 110 | 111 | {isOwnProfile && onCast && ( 112 | 117 | @{username} 118 | Cast 119 | 120 | )} 121 | 122 | 123 | 128 | 129 |
130 | 131 | 132 | 133 | {displayName} 134 | {hasPowerBadge && ( 135 | 136 | 137 | 138 | )} 139 | 140 | 141 | @{username} 142 | {isFollowing && Follows you} 143 | 144 | 145 | 146 | {isOwnProfile && ( 147 | 148 | Edit Profile 149 | 150 | )} 151 | 152 | 153 | 154 | 155 |
{linkifiedBio}
156 |
157 | 158 | 159 | 160 | {formattedFollowingCount} Following 161 | 162 | 163 | {formattedFollowersCount} Followers 164 | 165 | 166 |
167 |
168 |
169 | ); 170 | } 171 | ); 172 | -------------------------------------------------------------------------------- /src/components/NeynarProfileCard/hooks/useLinkifyBio.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { styled } from "@pigment-css/react"; 3 | 4 | const WARPCAST_DOMAIN = "https://warpcast.com"; 5 | 6 | const channelRegex = /\/\w+/g; 7 | const mentionRegex = /@\w+/g; 8 | const urlRegex = /((https?:\/\/)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(\/[^\s]*)?)/g; 9 | const combinedRegex = new RegExp( 10 | `(${channelRegex.source})|(${mentionRegex.source})|(${urlRegex.source})`, 11 | "g" 12 | ); 13 | 14 | const generateUrl = (match: string): string => { 15 | if (channelRegex.test(match)) { 16 | return `${WARPCAST_DOMAIN}/~/channel${match}`; 17 | } else if (mentionRegex.test(match)) { 18 | return `${WARPCAST_DOMAIN}/${match.substring(1)}`; 19 | } else if (urlRegex.test(match)) { 20 | return match.startsWith("http") ? match : `http://${match}`; 21 | } 22 | return ""; 23 | }; 24 | 25 | const StyledLink = styled.a(({ theme }) => ({ 26 | textDecoration: "underline", 27 | color: theme.vars.colors.primary, 28 | })); 29 | 30 | export const useLinkifyBio = (text: string): React.ReactNode[] => { 31 | const elements: React.ReactNode[] = []; 32 | let lastIndex = 0; 33 | 34 | let match; 35 | while ((match = combinedRegex.exec(text)) !== null) { 36 | const matchIndex = match.index; 37 | if (lastIndex < matchIndex) { 38 | elements.push(text.slice(lastIndex, matchIndex)); 39 | } 40 | 41 | const url = generateUrl(match[0]); 42 | elements.push( 43 | 44 | {match[0]} 45 | 46 | ); 47 | 48 | lastIndex = combinedRegex.lastIndex; 49 | } 50 | 51 | if (lastIndex < text.length) { 52 | elements.push(text.slice(lastIndex)); 53 | } 54 | 55 | return elements; 56 | }; 57 | -------------------------------------------------------------------------------- /src/components/NeynarProfileCard/icons/WarpcastPowerBadge.tsx: -------------------------------------------------------------------------------- 1 | export const WarpcastPowerBadge = () => { 2 | return ( 3 | 4 | 5 | 6 | 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/NeynarProfileCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from "react"; 2 | import { ProfileCard } from "./components/ProfileCard"; 3 | import { useNeynarContext } from "../../contexts"; 4 | import { NEYNAR_API_URL } from "../../constants"; 5 | 6 | async function fetchUserByFid({ 7 | fid, 8 | viewerFid, 9 | clientId, 10 | }: { 11 | fid: number; 12 | viewerFid?: number; 13 | clientId: string; 14 | }): Promise { 15 | try { 16 | let url = `${NEYNAR_API_URL}/v2/farcaster/user/bulk?client_id=${clientId}&fids=${fid}`; 17 | 18 | if (viewerFid) { 19 | url += `&viewer_fid=${viewerFid}`; 20 | } 21 | 22 | const response = await fetch(url); 23 | const data = await response.json(); 24 | return data?.users?.[0] ?? null; 25 | } catch (error) { 26 | console.error("Error fetching user by fid", error); 27 | return null; 28 | } 29 | } 30 | 31 | export type NeynarProfileCardProps = { 32 | fid: number; 33 | viewerFid?: number; 34 | }; 35 | 36 | export const NeynarProfileCard: React.FC = ({ 37 | fid, 38 | viewerFid, 39 | }) => { 40 | const { client_id } = useNeynarContext(); 41 | 42 | const [userData, setUserData] = useState(null); 43 | const [loading, setLoading] = useState(true); 44 | const [error, setError] = useState(null); 45 | 46 | const isOwnProfile = userData?.fid === viewerFid; 47 | 48 | useEffect(() => { 49 | if (fid) { 50 | setLoading(true); 51 | setError(null); 52 | 53 | fetchUserByFid({ fid, viewerFid, clientId: client_id }) 54 | .then((data) => { 55 | setUserData(data); 56 | }) 57 | .catch((error) => { 58 | setError(error); 59 | }) 60 | .finally(() => { 61 | setLoading(false); 62 | }); 63 | } 64 | }, [fid, viewerFid]); 65 | 66 | const handleCast = useCallback(() => { 67 | // TODO: Handle cast 68 | }, []); 69 | 70 | if (loading) { 71 | return
Loading...
; 72 | } 73 | 74 | if (!userData || error) { 75 | return
Error fetching user data
; 76 | } 77 | 78 | return ( 79 | 91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { NeynarAuthButton } from "./NeynarAuthButton"; 2 | export { NeynarProfileCard } from "./NeynarProfileCard"; 3 | -------------------------------------------------------------------------------- /src/components/shared/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@pigment-css/react"; 2 | 3 | const Avatar = styled.img(() => ({ 4 | width: "45px", 5 | height: "45px", 6 | borderRadius: "50%", 7 | aspectRatio: 1 / 1, 8 | objectFit: "cover", 9 | })); 10 | 11 | export default Avatar; 12 | -------------------------------------------------------------------------------- /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?: 7 | | "center" 8 | | "flex-start" 9 | | "flex-end" 10 | | "space-between" 11 | | "space-around" 12 | | "space-evenly"; 13 | flexGrow?: number; 14 | flexShrink?: number; 15 | spacing?: string; 16 | spacingTop?: string; 17 | spacingRight?: string; 18 | spacingBottom?: string; 19 | spacingLeft?: string; 20 | spacingVertical?: string; 21 | spacingHorizontal?: string; 22 | } 23 | 24 | export const Box = styled.div({ 25 | display: "flex", 26 | alignItems: (props) => props.alignItems || "flex-start", 27 | justifyContent: (props) => props.justifyContent || "flex-start", 28 | flexGrow: (props) => props.flexGrow || "initial", 29 | flexShrink: (props) => props.flexShrink || "initial", 30 | marginTop: (props) => 31 | props.spacing ?? props.spacingVertical ?? props.spacingTop ?? "0px", 32 | marginRight: (props) => 33 | props.spacing ?? props.spacingHorizontal ?? props.spacingRight ?? "0px", 34 | marginBottom: (props) => 35 | props.spacing ?? props.spacingVertical ?? props.spacingBottom ?? "0px", 36 | marginLeft: (props) => 37 | props.spacing ?? props.spacingHorizontal ?? props.spacingLeft ?? "0px", 38 | }); 39 | 40 | export const VBox = styled(Box)({ 41 | flexDirection: "column", 42 | }); 43 | 44 | export const HBox = styled(Box)({ 45 | flexDirection: "row", 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/shared/ButtonOutline/index.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@pigment-css/react"; 2 | 3 | const ButtonOutline = styled.button(({ theme }) => ({ 4 | borderWidth: "1px", 5 | borderStyle: "solid", 6 | borderColor: theme.vars.palette.border, 7 | borderRadius: "7px", 8 | padding: "10px", 9 | backgroundColor: "transparent", 10 | color: theme.vars.palette.text, 11 | fontWeight: theme.typography.fontWeights.bold, 12 | lineHeight: 1, 13 | cursor: "pointer", 14 | "& + &": { 15 | marginLeft: "10px", 16 | }, 17 | })); 18 | 19 | export default ButtonOutline; 20 | -------------------------------------------------------------------------------- /src/components/shared/ButtonPrimary/index.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@pigment-css/react"; 2 | 3 | const ButtonPrimary = styled.button(({ theme }) => ({ 4 | border: "none", 5 | borderRadius: "7px", 6 | padding: "13px 15px", 7 | backgroundColor: theme.colors.primary, 8 | color: "#fff", 9 | fontWeight: theme.typography.fontWeights.bold, 10 | lineHeight: 1, 11 | cursor: "pointer", 12 | "& + &": { 13 | marginLeft: "10px", 14 | }, 15 | })); 16 | 17 | export default ButtonPrimary; 18 | -------------------------------------------------------------------------------- /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/components/stories/NeynarProfileCard.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Meta, StoryFn } from "@storybook/react"; 3 | import { ProfileCard, ProfileCardProps } from "../NeynarProfileCard/components/ProfileCard"; 4 | import { NeynarProfileCard, NeynarProfileCardProps } from "../NeynarProfileCard/index"; 5 | 6 | const meta: Meta = { 7 | title: "NeynarProfileCard", 8 | component: NeynarProfileCard, 9 | }; 10 | 11 | export default meta; 12 | 13 | const Template: StoryFn = (args) => ; 14 | const TemplateWithUser: StoryFn = ({ fid, viewerFid }) => ( 15 | 16 | ); 17 | 18 | export const Primary = Template.bind({}); 19 | Primary.args = { 20 | username: "rish", 21 | displayName: "rish", 22 | avatarImgUrl: "https://i.imgur.com/naZWL9n.gif", 23 | bio: "building /neynar 🪐 | neynar.com | /rish", 24 | followers: 127364, 25 | following: 676, 26 | hasPowerBadge: true, 27 | isOwnProfile: true, 28 | isFollowing: true, 29 | onCast: () => {}, 30 | }; 31 | Primary.argTypes = { 32 | fid: { table: { disable: true } }, 33 | viewerFid: { table: { disable: true } }, 34 | onCast: { table: { disable: true } }, 35 | }; 36 | 37 | export const WithUser = TemplateWithUser.bind({}); 38 | WithUser.args = { 39 | fid: 1, 40 | viewerFid: 1, 41 | }; 42 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const NEYNAR_API_URL = "https://sdk-api.neynar.com"; -------------------------------------------------------------------------------- /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: INeynarAuthenticatedUser }) => 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: INeynarAuthenticatedUser }) => 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: INeynarAuthenticatedUser }) => { 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: INeynarAuthenticatedUser }) => 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 | } else if (theme === "dark") { 72 | root.classList.add("theme-dark"); 73 | root.classList.remove("theme-light"); 74 | } 75 | } 76 | }, [theme]); 77 | 78 | const _setIsAuthenticated = (_isAuthenticated: boolean) => { 79 | setIsAuthenticated(_isAuthenticated); 80 | }; 81 | 82 | const _setUser = (_user: INeynarAuthenticatedUser | null) => { 83 | setUser(_user); 84 | }; 85 | 86 | const logoutUser = () => { 87 | if (user) { 88 | const { signer_uuid, ...rest } = user; 89 | setUser(null); 90 | setIsAuthenticated(false); 91 | localStorage.removeItem(LocalStorageKeys.NEYNAR_AUTHENTICATED_USER); 92 | if (eventsCallbacks?.onSignout) { 93 | eventsCallbacks.onSignout(rest); 94 | } 95 | } 96 | }; 97 | 98 | const value = useMemo( 99 | () => ({ 100 | client_id, 101 | theme, 102 | isAuthenticated, 103 | user, 104 | setTheme, 105 | showToast, 106 | logoutUser, 107 | }), 108 | [client_id, theme, isAuthenticated, user, setTheme, showToast, logoutUser] 109 | ); 110 | 111 | return ( 112 | 113 | 121 | {children} 122 | 123 | {toasts.map((toast, index) => ( 124 | 125 | {toast.message} 126 | 127 | ))} 128 | 129 | 130 | 131 | ); 132 | }; 133 | 134 | export const useNeynarContext = () => { 135 | const context = useContext(NeynarContext); 136 | if (!context) { 137 | throw new Error( 138 | "useNeynarContext must be used within a NeynarContextProvider" 139 | ); 140 | } 141 | return context; 142 | }; 143 | -------------------------------------------------------------------------------- /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 | Dark = "dark", 4 | } 5 | 6 | export enum SIWN_variant { 7 | FARCASTER = "farcaster", 8 | NEYNAR = "neynar", 9 | WARPCAST = "warpcast", 10 | } 11 | -------------------------------------------------------------------------------- /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 | border: "#E0E0E0", 9 | text: "#2B2432", 10 | textMuted: "#6A6A6C", 11 | }, 12 | }, 13 | dark: { 14 | palette: { 15 | background: "#15111D", 16 | border: "#2E3031", 17 | text: "#FFFFFF", 18 | textMuted: "#A0A3AD", 19 | }, 20 | }, 21 | }, 22 | colors: { 23 | primary: "#8A63D2", 24 | }, 25 | typography: { 26 | fonts: { 27 | base: "Sora, sans-serif", 28 | }, 29 | fontWeights: { 30 | regular: 400, 31 | bold: 700, 32 | }, 33 | fontSizes: { 34 | large: "20px", 35 | medium: "15px", 36 | small: "12px", 37 | }, 38 | }, 39 | getSelector: (colorScheme) => 40 | colorScheme ? `.theme-${colorScheme}` : ":root", 41 | }); 42 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/formatUtils.ts: -------------------------------------------------------------------------------- 1 | export function formatToReadableNumber(num: number): string { 2 | // Cap the number to 1 billion 3 | const cappedNum = Math.min(num, 1_000_000_000); 4 | if (cappedNum >= 1_000_000_000) { 5 | return Math.floor(cappedNum / 100_000_000) / 10 + "B"; 6 | } 7 | if (cappedNum >= 1_000_000) { 8 | return Math.floor(cappedNum / 100_000) / 10 + "M"; 9 | } 10 | if (cappedNum >= 1_000) { 11 | return Math.floor(cappedNum / 100) / 10 + "K"; 12 | } 13 | return cappedNum.toString(); 14 | } 15 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------