Skip to content

Commit 46cbaec

Browse files
authored
Add POAPs section (#97)
1 parent d9e48e7 commit 46cbaec

File tree

6 files changed

+229
-0
lines changed

6 files changed

+229
-0
lines changed

packages/nextjs/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
NEXT_PUBLIC_OPENSEA_API_KEY=
1010
NEXT_PUBLIC_ALCHEMY_API_KEY=
1111
NEXT_PUBLIC_MORALIS_API_KEY=
12+
NEXT_PUBLIC_POAP_API_KEY=
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { useEffect, useRef, useState } from "react";
2+
import Image from "next/image";
3+
import Link from "next/link";
4+
import useSWR from "swr";
5+
import { isAddress } from "viem";
6+
import { useAddressStore } from "~~/services/store/store";
7+
import { poapFetcher } from "~~/utils/scaffold-eth";
8+
9+
type POAPEvent = {
10+
id: number;
11+
fancy_id: string;
12+
name: string;
13+
event_url: string;
14+
image_url: string;
15+
country: string;
16+
city: string;
17+
description: string;
18+
year: number;
19+
start_date: string;
20+
end_date: string;
21+
timezone: string;
22+
expiry_date: string;
23+
supply: number;
24+
};
25+
26+
type POAP = {
27+
event: POAPEvent;
28+
tokenId: string;
29+
owner: string;
30+
chain: string;
31+
created: string;
32+
};
33+
34+
export const POAPCard = () => {
35+
const { resolvedAddress: address } = useAddressStore();
36+
37+
const shouldFetch = address && isAddress(address);
38+
39+
const { data: poapData, error: poapError } = useSWR(
40+
shouldFetch ? `https://api.poap.tech/actions/scan/${address}` : null,
41+
poapFetcher,
42+
{
43+
revalidateOnFocus: false,
44+
revalidateOnReconnect: false,
45+
dedupingInterval: 60000,
46+
},
47+
);
48+
49+
const carouselRef = useRef<HTMLDivElement>(null);
50+
const [isAtStart, setIsAtStart] = useState(true);
51+
const [isAtEnd, setIsAtEnd] = useState(false);
52+
const [imageErrors, setImageErrors] = useState<{ [key: string]: boolean }>({});
53+
54+
const updateButtonState = () => {
55+
if (carouselRef.current) {
56+
const element = carouselRef.current;
57+
setIsAtStart(element.scrollLeft === 0);
58+
setIsAtEnd(element.scrollLeft + element.clientWidth >= element.scrollWidth);
59+
}
60+
};
61+
62+
useEffect(() => {
63+
updateButtonState();
64+
}, [poapData]);
65+
66+
const scrollLeft = () => {
67+
if (carouselRef.current) {
68+
carouselRef.current.scrollBy({ left: -200, behavior: "smooth" });
69+
setTimeout(updateButtonState, 300);
70+
}
71+
};
72+
73+
const scrollRight = () => {
74+
if (carouselRef.current) {
75+
carouselRef.current.scrollBy({ left: 200, behavior: "smooth" });
76+
setTimeout(updateButtonState, 300);
77+
}
78+
};
79+
80+
const handleImageError = (tokenId: string) => {
81+
setImageErrors(prevErrors => ({ ...prevErrors, [tokenId]: true }));
82+
};
83+
84+
// Sort POAPs by created date (most recent first) and take the last 5
85+
const sortedPoaps = poapData
86+
? [...poapData]
87+
.sort((a: POAP, b: POAP) => new Date(b.created).getTime() - new Date(a.created).getTime())
88+
.slice(0, 5)
89+
: [];
90+
91+
const isPoapsLoading = poapData === undefined && !poapError;
92+
const totalPoaps = poapData?.length || 0;
93+
94+
if (!shouldFetch || isPoapsLoading) {
95+
return (
96+
<div className="card w-[370px] md:w-[425px] bg-base-100 shadow-xl flex-grow animate-pulse">
97+
<div className="card-body">
98+
<div className="flex items-center space-x-4">
99+
<div className="h-2 w-28 bg-slate-300 rounded"></div>
100+
</div>
101+
102+
<h3 className="font-bold mt-4">POAPs</h3>
103+
<div className="relative flex flex-col">
104+
<div className="carousel-center carousel rounded-box max-w-md space-x-4 bg-secondary p-4">
105+
<div className="carousel-item">
106+
<div className="rounded-md bg-slate-300 h-32 w-32"></div>
107+
</div>
108+
<div className="carousel-item">
109+
<div className="rounded-md bg-slate-300 h-32 w-32"></div>
110+
</div>
111+
<div className="carousel-item">
112+
<div className="rounded-md bg-slate-300 h-32 w-32"></div>
113+
</div>
114+
</div>
115+
</div>
116+
</div>
117+
</div>
118+
);
119+
}
120+
121+
return (
122+
<div className="card w-[370px] md:w-[425px] bg-base-100 shadow-xl flex-grow">
123+
<div className="card-body py-6">
124+
<h2 className="card-title whitespace-nowrap flex items-center gap-2">
125+
<span className="text-sm md:text-base">POAPs</span>
126+
{totalPoaps > 0 && <span className="text-xs text-base-content/60">({totalPoaps} total)</span>}
127+
</h2>
128+
<h3 className="font-bold">Recent POAPs</h3>
129+
{poapError ? (
130+
<div className="text-sm text-error">Unable to load POAPs</div>
131+
) : sortedPoaps.length === 0 ? (
132+
<div className="text-sm">No POAP data.</div>
133+
) : (
134+
<>
135+
<div className="relative flex flex-col">
136+
{sortedPoaps.length > 1 && (
137+
<>
138+
<button
139+
onClick={scrollLeft}
140+
className="btn btn-sm btn-circle opacity-60 absolute z-20 left-2 top-16"
141+
disabled={isAtStart}
142+
>
143+
144+
</button>
145+
<button
146+
onClick={scrollRight}
147+
className="btn btn-sm btn-circle opacity-60 absolute z-20 right-2 top-16"
148+
disabled={isAtEnd}
149+
>
150+
151+
</button>
152+
</>
153+
)}
154+
<div
155+
ref={carouselRef}
156+
className="carousel-center carousel rounded-box max-w-md space-x-4 bg-secondary p-4 z-10"
157+
>
158+
{sortedPoaps.map((poap: POAP) => (
159+
<div className="carousel-item" key={poap.tokenId}>
160+
<a
161+
href={`https://collectors.poap.xyz/token/${poap.tokenId}`}
162+
target="_blank"
163+
rel="noopener noreferrer"
164+
className="flex h-32 w-32 items-center justify-center"
165+
>
166+
<div className="flex h-full w-full items-center justify-center">
167+
<Image
168+
src={imageErrors[poap.tokenId] ? "/base.svg" : poap.event.image_url}
169+
className="rounded-box h-full w-full object-contain"
170+
alt={poap.event.name}
171+
width={128}
172+
height={128}
173+
onError={() => {
174+
handleImageError(poap.tokenId);
175+
}}
176+
/>
177+
</div>
178+
</a>
179+
</div>
180+
))}
181+
</div>
182+
</div>
183+
{address && (
184+
<div className="self-end flex items-start gap-2 pt-2">
185+
<p className="text-xs m-0">See all on </p>
186+
<Link
187+
href={`https://collectors.poap.xyz/scan/${address}`}
188+
rel="noopener noreferrer"
189+
target="_blank"
190+
className="text-xs text-primary hover:underline"
191+
>
192+
POAP.xyz
193+
</Link>
194+
</div>
195+
)}
196+
</>
197+
)}
198+
</div>
199+
</div>
200+
);
201+
};

packages/nextjs/components/address-vision/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from "./ButtonsCard";
33
export * from "./Navbar";
44
export * from "./NetworkCard";
55
export * from "./NftsCarousel";
6+
export * from "./POAPCard";
67
export * from "./QRCodeCard";
78
export * from "./SafeOwner";
89
export * from "./TokensTable";

packages/nextjs/pages/[address].tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ButtonsCard,
1010
Navbar,
1111
NetworkCard,
12+
POAPCard,
1213
QRCodeCard,
1314
TotalBalanceCard,
1415
} from "~~/components/address-vision/";
@@ -63,6 +64,7 @@ const AddressPage: NextPage<Props> = ({ address }) => {
6364
</div>
6465
<ButtonsCard />
6566
<TotalBalanceCard />
67+
<POAPCard />
6668
<NetworkCard chain={chains.arbitrum} />
6769
<div className="lg:hidden">
6870
<NetworkCard chain={chains.polygon} />

packages/nextjs/utils/scaffold-eth/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from "./moralisFetcher";
33
export * from "./networks";
44
export * from "./notification";
55
export * from "./openseaNftFetcher";
6+
export * from "./poapFetcher";
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export const poapFetcher = async (url: string) => {
2+
const apiKey = process.env.NEXT_PUBLIC_POAP_API_KEY;
3+
4+
if (!apiKey) {
5+
throw new Error("POAP API key is not defined.");
6+
}
7+
8+
const options = {
9+
method: "GET",
10+
headers: {
11+
accept: "application/json",
12+
"x-api-key": apiKey,
13+
},
14+
};
15+
16+
const response = await fetch(url, options);
17+
if (!response.ok) {
18+
const error = new Error("An error occurred while fetching the data.");
19+
error.message = await response.text();
20+
throw error;
21+
}
22+
return response.json();
23+
};

0 commit comments

Comments
 (0)