Hey is a decentralized and permissionless social media app built with Lens Protocol 馃尶
at main 291 lines 9.7 kB view raw
1import { 2 CheckCircleIcon, 3 ClockIcon, 4 CurrencyDollarIcon, 5 PhotoIcon, 6 PuzzlePieceIcon, 7 UsersIcon 8} from "@heroicons/react/24/outline"; 9import { BLOCK_EXPLORER_URL } from "@hey/data/constants"; 10import { tokens } from "@hey/data/tokens"; 11import formatAddress from "@hey/helpers/formatAddress"; 12import getAccount from "@hey/helpers/getAccount"; 13import { isRepost } from "@hey/helpers/postHelpers"; 14import { 15 type AnyPostFragment, 16 type SimpleCollectActionFragment, 17 useCollectActionQuery 18} from "@hey/indexer"; 19import { useCounter } from "@uidotdev/usehooks"; 20import dayjs from "dayjs"; 21import plur from "plur"; 22import { type Dispatch, type SetStateAction, useMemo, useState } from "react"; 23import { Link } from "react-router"; 24import CountdownTimer from "@/components/Shared/CountdownTimer"; 25import Loader from "@/components/Shared/Loader"; 26import PostExecutors from "@/components/Shared/Modal/PostExecutors"; 27import Slug from "@/components/Shared/Slug"; 28import { 29 H3, 30 H4, 31 HelpTooltip, 32 Modal, 33 Tooltip, 34 WarningMessage 35} from "@/components/Shared/UI"; 36import getTokenImage from "@/helpers/getTokenImage"; 37import humanize from "@/helpers/humanize"; 38import nFormatter from "@/helpers/nFormatter"; 39import CollectActionButton from "./CollectActionButton"; 40import Splits from "./Splits"; 41 42interface CollectActionBodyProps { 43 post: AnyPostFragment; 44 setShowCollectModal: Dispatch<SetStateAction<boolean>>; 45} 46 47const CollectActionBody = ({ 48 post, 49 setShowCollectModal 50}: CollectActionBodyProps) => { 51 const [showCollectorsModal, setShowCollectorsModal] = useState(false); 52 const targetPost = isRepost(post) ? post?.repostOf : post; 53 const [collects, { increment }] = useCounter(targetPost.stats.collects); 54 55 const { data, loading } = useCollectActionQuery({ 56 variables: { request: { post: post.id } } 57 }); 58 59 // Memoize expensive calculations to prevent unnecessary re-renders 60 const enabledTokens = useMemo(() => { 61 return tokens.map((t) => t.symbol); 62 }, []); 63 64 // Extract data safely with optional chaining 65 const targetAction = useMemo(() => { 66 return data?.post?.__typename === "Post" 67 ? data?.post.actions.find( 68 (action) => action.__typename === "SimpleCollectAction" 69 ) 70 : data?.post?.__typename === "Repost" 71 ? data?.post?.repostOf?.actions.find( 72 (action) => action.__typename === "SimpleCollectAction" 73 ) 74 : null; 75 }, [data]); 76 77 const collectAction = targetAction as SimpleCollectActionFragment; 78 const endTimestamp = collectAction?.endsAt; 79 const collectLimit = useMemo( 80 () => Number(collectAction?.collectLimit || 0), 81 [collectAction] 82 ); 83 const amount = useMemo( 84 () => Number.parseFloat(collectAction?.payToCollect?.price?.value || "0"), 85 [collectAction] 86 ); 87 const currency = collectAction?.payToCollect?.price?.asset?.symbol; 88 const recipients = collectAction?.payToCollect?.recipients || []; 89 90 const percentageCollected = useMemo(() => { 91 return collectLimit > 0 ? (collects / collectLimit) * 100 : 0; 92 }, [collects, collectLimit]); 93 94 const isTokenEnabled = useMemo(() => { 95 return enabledTokens?.includes(currency || ""); 96 }, [enabledTokens, currency]); 97 98 const isSaleEnded = useMemo(() => { 99 return endTimestamp 100 ? new Date(endTimestamp).getTime() / 1000 < new Date().getTime() / 1000 101 : false; 102 }, [endTimestamp]); 103 104 const isAllCollected = useMemo(() => { 105 return collectLimit ? collects >= collectLimit : false; 106 }, [collectLimit, collects]); 107 108 const totalRevenue = useMemo(() => { 109 return amount * collects; 110 }, [amount, collects]); 111 112 const heyFee = useMemo(() => { 113 return (amount * 0.025).toFixed(2); 114 }, [amount]); 115 116 if (loading) { 117 return <Loader className="my-10" />; 118 } 119 120 return ( 121 <> 122 {collectLimit ? ( 123 <Tooltip 124 content={`${percentageCollected.toFixed(0)}% Collected`} 125 placement="top" 126 > 127 <div className="h-2.5 w-full bg-gray-200 dark:bg-gray-700"> 128 <div 129 className="h-2.5 bg-black dark:bg-white" 130 style={{ width: `${percentageCollected}%` }} 131 /> 132 </div> 133 </Tooltip> 134 ) : null} 135 <div className="p-5"> 136 {isAllCollected ? ( 137 <WarningMessage 138 className="mb-5" 139 message={ 140 <div className="flex items-center space-x-1.5"> 141 <CheckCircleIcon className="size-4" /> 142 <span>This collection has been sold out</span> 143 </div> 144 } 145 /> 146 ) : isSaleEnded ? ( 147 <WarningMessage 148 className="mb-5" 149 message={ 150 <div className="flex items-center space-x-1.5"> 151 <ClockIcon className="size-4" /> 152 <span>This collection has ended</span> 153 </div> 154 } 155 /> 156 ) : null} 157 <div className="mb-4"> 158 <H4> 159 {targetPost.__typename} by{" "} 160 <Slug slug={getAccount(targetPost.author).username} /> 161 </H4> 162 </div> 163 {amount ? ( 164 <div className="flex items-center space-x-1.5 py-2"> 165 {isTokenEnabled ? ( 166 <img 167 alt={currency} 168 className="size-7 rounded-full" 169 height={28} 170 src={getTokenImage(currency)} 171 title={currency} 172 width={28} 173 /> 174 ) : ( 175 <CurrencyDollarIcon className="size-7" /> 176 )} 177 <span className="space-x-1"> 178 <H3 as="span">{amount}</H3> 179 <span className="text-xs">{currency}</span> 180 </span> 181 <div className="mt-2"> 182 <HelpTooltip> 183 <div className="py-1"> 184 <div className="flex items-start justify-between space-x-10"> 185 <div>Hey</div> 186 <b> 187 ~{heyFee} {currency} (2.5%) 188 </b> 189 </div> 190 </div> 191 </HelpTooltip> 192 </div> 193 </div> 194 ) : null} 195 <div className="space-y-1.5"> 196 <div className="block items-center space-y-1 sm:flex sm:space-x-5"> 197 <div className="flex items-center space-x-2"> 198 <UsersIcon className="size-4 text-gray-500 dark:text-gray-200" /> 199 <button 200 className="font-bold" 201 onClick={() => setShowCollectorsModal(true)} 202 type="button" 203 > 204 {humanize(collects)} {plur("collector", collects)} 205 </button> 206 </div> 207 {collectLimit && !isAllCollected ? ( 208 <div className="flex items-center space-x-2"> 209 <PhotoIcon className="size-4 text-gray-500 dark:text-gray-200" /> 210 <div className="font-bold"> 211 {collectLimit - collects} available 212 </div> 213 </div> 214 ) : null} 215 </div> 216 {endTimestamp && !isAllCollected ? ( 217 <div className="flex items-center space-x-2"> 218 <ClockIcon className="size-4 text-gray-500 dark:text-gray-200" /> 219 <div className="space-x-1.5"> 220 <span>{isSaleEnded ? "Sale ended on:" : "Sale ends:"}</span> 221 <span className="font-bold text-gray-600"> 222 {isSaleEnded ? ( 223 `${dayjs(endTimestamp).format("MMM D, YYYY, h:mm A")}` 224 ) : ( 225 <CountdownTimer targetDate={endTimestamp} /> 226 )} 227 </span> 228 </div> 229 </div> 230 ) : null} 231 {collectAction.address ? ( 232 <div className="flex items-center space-x-2"> 233 <PuzzlePieceIcon className="size-4 text-gray-500 dark:text-gray-200" /> 234 <div className="space-x-1.5"> 235 <span>Token:</span> 236 <Link 237 className="font-bold text-gray-600" 238 rel="noreferrer noopener" 239 target="_blank" 240 to={`${BLOCK_EXPLORER_URL}/address/${collectAction.address}`} 241 > 242 {formatAddress(collectAction.address)} 243 </Link> 244 </div> 245 </div> 246 ) : null} 247 {amount ? ( 248 <div className="flex items-center space-x-2"> 249 <CurrencyDollarIcon className="size-4 text-gray-500 dark:text-gray-200" /> 250 <div className="space-x-1.5"> 251 <span>Revenue:</span> 252 <Tooltip 253 content={`${humanize(totalRevenue)} ${currency}`} 254 placement="top" 255 > 256 <span className="font-bold text-gray-600"> 257 {nFormatter(totalRevenue)} {currency} 258 </span> 259 </Tooltip> 260 </div> 261 </div> 262 ) : null} 263 {recipients.length > 1 ? <Splits recipients={recipients} /> : null} 264 </div> 265 <div className="flex items-center space-x-2"> 266 <CollectActionButton 267 collects={collects} 268 onCollectSuccess={() => { 269 increment(); 270 setShowCollectModal(false); 271 }} 272 post={targetPost} 273 postAction={collectAction} 274 /> 275 </div> 276 </div> 277 <Modal 278 onClose={() => setShowCollectorsModal(false)} 279 show={showCollectorsModal} 280 title="Collectors" 281 > 282 <PostExecutors 283 filter={{ simpleCollect: true }} 284 postId={targetPost.id} 285 /> 286 </Modal> 287 </> 288 ); 289}; 290 291export default CollectActionBody;