Hey is a decentralized and permissionless social media app built with Lens Protocol 馃尶
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;