diff --git a/package-lock.json b/package-lock.json index 570f9f4..1579c9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -68,6 +68,7 @@ "react-social-icons": "^6.25.0", "recharts": "^2.15.4", "sonner": "^1.7.4", + "swiper": "^12.0.3", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.9", @@ -16740,6 +16741,25 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swiper": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.0.3.tgz", + "integrity": "sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/swiperjs" + }, + { + "type": "open_collective", + "url": "http://opencollective.com/swiper" + } + ], + "license": "MIT", + "engines": { + "node": ">= 4.7.0" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", diff --git a/package.json b/package.json index 06d672b..0b4d54d 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "react-social-icons": "^6.25.0", "recharts": "^2.15.4", "sonner": "^1.7.4", + "swiper": "^12.0.3", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "vaul": "^0.9.9", diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 685bd08..bc118d0 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -40,6 +40,7 @@ import { OneTimeTasksSection } from "@/components/dashboard/OneTimeTasksSection" import { SBTGatingModal } from "@/components/modals/SBTGatingModal" import { TransactionFeedbackModal } from "@/components/modals/TransactionFeedbackModal" import { ReferralModal } from "@/components/modals/ReferralModal" +import { EcosystemSetCarousel } from "@/components/dashboard/EcosystemSetsCarousel" import type { TaskName } from "@/hooks/use-dashboard-tasks" @@ -204,6 +205,8 @@ const DashboardContent = () => { + + +): Promise<{ address: string } | { error: NextResponse }> { + const { wallet_address } = await params + + if (!wallet_address) { + return { + error: NextResponse.json({ error: "Wallet address is required" }, { status: 400 }), + } + } + + if (!isValidWalletAddress(wallet_address)) { + return { + error: NextResponse.json({ error: "Invalid wallet address format" }, { status: 400 }), + } + } + + return { address: wallet_address.toLowerCase() } +} + +/** + * GET /api/user-community-activity/[wallet_address] + * Returns the latest activity per entity for the user (e.g. ecosystem set verifications). + */ +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ wallet_address: string }> } +) { + try { + const result = await getValidatedWalletAddress(params) + if ("error" in result) return result.error + + const { rows } = await pool.query( + `SELECT entity, activity + FROM ( + SELECT entity, activity, + ROW_NUMBER() OVER (PARTITION BY entity ORDER BY created_at DESC) AS rn + FROM user_activity + WHERE user_address = $1 + ) sub + WHERE rn = 1`, + [result.address] + ) + + const activities: Record = {} + for (const row of rows) { + activities[row.entity] = row.activity === true + } + + return NextResponse.json({ activities }) + } catch (err) { + console.error("Error fetching user community activity:", err) + return NextResponse.json({ error: "Database query failed" }, { status: 500 }) + } +} + +/** + * POST /api/user-community-activity/[wallet_address] + * Save or update one entity's activity. Body: { entity: string, activity: boolean, chainId?: number } + */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ wallet_address: string }> } +) { + try { + const result = await getValidatedWalletAddress(params) + if ("error" in result) return result.error + + const body = await request.json() + const entity = typeof body?.entity === "string" ? body.entity.trim() : null + const activity = body?.activity === true || body?.activity === "true" + const chainId = + typeof body?.chainId === "number" && Number.isInteger(body.chainId) ? body.chainId : null + + if (!entity) { + return NextResponse.json( + { error: "entity is required and must be a non-empty string" }, + { status: 400 } + ) + } + + await pool.query( + `INSERT INTO user_activity (user_address, entity, activity, chainid) + VALUES ($1, $2, $3, $4)`, + [result.address, entity, activity, chainId] + ) + + return NextResponse.json({ ok: true, entity, activity, chainId }, { status: 201 }) + } catch (err) { + console.error("Error saving user community activity:", err) + return NextResponse.json({ error: "Database operation failed" }, { status: 500 }) + } +} diff --git a/src/components/dashboard/EcosystemSetsCarousel.tsx b/src/components/dashboard/EcosystemSetsCarousel.tsx new file mode 100644 index 0000000..f36de01 --- /dev/null +++ b/src/components/dashboard/EcosystemSetsCarousel.tsx @@ -0,0 +1,476 @@ +import React, { useState, useEffect, useCallback, useMemo } from "react" +import useEmblaCarousel from "embla-carousel-react" +import { ShieldCheck, ChevronLeft, ChevronRight, Loader2, Check, X } from "lucide-react" +import { useAccount, useReadContracts } from "wagmi" +import { erc721Abi } from "viem" + +const ASSETS_URL = process.env.NEXT_PUBLIC_R2_BASE_URL + +const AZUKI_IMG = `${ASSETS_URL}/nfts/azuki.jpg` +const DOODLES_IMG = `${ASSETS_URL}/nfts/doodles.jpg` +const MOONBIRDS_IMG = `${ASSETS_URL}/nfts/moonbirds.jpg` +const PUDGY_PENGUINS_IMG = `${ASSETS_URL}/nfts/pudgy-penguins.jpg` +const YUGA_LABS_IMG = `${ASSETS_URL}/nfts/yuga-labs.jpg` +const TEST_IMG = `/assets/fast-icon.png` + +const CHAIN_ETH = 1 +const CHAIN_BSC = 56 + +const CHAIN_NAMES: Record = { + [CHAIN_ETH]: "Ethereum", + [CHAIN_BSC]: "BSC", +} + +type ContractEntry = { + address: `0x${string}` + chainId: number + label: string +} + +const ECOSYSTEM_SETS: { + id: string + name: string + img: string + contracts: readonly ContractEntry[] +}[] = [ + // { + // id: "test", + // name: "Test", + // img: TEST_IMG, + // contracts: [ + // { address: "0xd0E132C73C9425072AAB9256d63aa14D798D063A", chainId: CHAIN_ETH, label: "Test" }, + // ], + // }, + { + id: "pudgy", + name: "Pudgy\nPenguins", + img: PUDGY_PENGUINS_IMG, + contracts: [ + { + address: "0xbd3531da5cf5857e7cfaa92426877b022e612cf8", + chainId: CHAIN_ETH, + label: "Pudgy Penguins (original)", + }, + { + address: "0x524cab2ec69124574082676e6f654a18df49a048", + chainId: CHAIN_ETH, + label: "Lil Pudgys", + }, + { + address: "0x062e691c2054de82f28008a8ccc6d7a1c8ce060d", + chainId: CHAIN_ETH, + label: "Pudgy Rods", + }, + ], + }, + { + id: "moonbirds", + name: "Moonbirds", + img: MOONBIRDS_IMG, + contracts: [ + { + address: "0x23581767a106ae21c074b2276d25e5c3e136a68b", + chainId: CHAIN_ETH, + label: "Moonbirds (original)", + }, + { + address: "0x1792a96e5668ad7c167ab804a100ce42395ce54d", + chainId: CHAIN_ETH, + label: "Moonbirds Oddities", + }, + { + address: "0xc0ffee8ff7e5497c2d6f7684859709225fcc5be8", + chainId: CHAIN_ETH, + label: "Moonbirds Mythics", + }, + ], + }, + { + id: "azuki", + name: "Azuki", + img: AZUKI_IMG, + contracts: [ + { + address: "0xed5af388653567af2f388e6224dc7c4b3241c544", + chainId: CHAIN_ETH, + label: "Azuki (primary)", + }, + { + address: "0x306b1ea3ecdf94ab739f1910bbda052ed4a9f949", + chainId: CHAIN_ETH, + label: "BEANZ Official", + }, + { + address: "0xb6a37b5d14d502c3ab0ae6f3a0e058bc9517786e", + chainId: CHAIN_ETH, + label: "Azuki Elementals", + }, + ], + }, + { + id: "yuga", + name: "Yuga Labs", + img: YUGA_LABS_IMG, + contracts: [ + { address: "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d", chainId: CHAIN_ETH, label: "BAYC" }, + { address: "0x60e4d786628fea6478f785a6d7e704777c86a7c6", chainId: CHAIN_ETH, label: "MAYC" }, + { address: "0xba30e5f9bb24caa003e9f2f0497ad287fdf95623", chainId: CHAIN_ETH, label: "BAKC" }, + { + address: "0x34d85c9cdeb23fa97cb08333b511ac86e1c4e258", + chainId: CHAIN_ETH, + label: "Otherdeed", + }, + { + address: "0xb47e3cd837ddf8e4c57f05d70ab865de6e193bbb", + chainId: CHAIN_ETH, + label: "CryptoPunks", + }, + { + address: "0x7bd29408f11d2bfc23c34f18275bbf23bb716bc7", + chainId: CHAIN_ETH, + label: "Meebits", + }, + ], + }, + { + id: "doodles", + name: "Doodles", + img: DOODLES_IMG, + contracts: [ + { + address: "0x8a90cab2b38dba80c64b7734e58ee1db38b8992e", + chainId: CHAIN_ETH, + label: "Doodles (original)", + }, + { + address: "0x89afdbf071050a67cfdc28b2ccb4277eef598f37", + chainId: CHAIN_ETH, + label: "Space Doodles", + }, + { + address: "0x466cfcd0525189b573e794f554b8a751279213ac", + chainId: CHAIN_ETH, + label: "The Dooplicator", + }, + ], + }, +] + +const fetchUserActivity = async (walletAddress: string): Promise> => { + const res = await fetch(`/api/user-community-activity/${walletAddress}`) + if (!res.ok) return {} + const data = await res.json() + return data.activities ?? {} +} + +const saveUserActivity = async ( + walletAddress: string, + entity: string, + activity: boolean, + chainId: number | null +) => { + const res = await fetch(`/api/user-community-activity/${walletAddress}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entity, activity, chainId }), + }) + if (!res.ok) throw new Error("Failed to save activity") +} + +export const EcosystemSetCarousel = () => { + const { address: userAddress, isConnected } = useAccount() + const [verifiedSets, setVerifiedSets] = useState>({}) + const [failedSets, setFailedSets] = useState>({}) + const [isInitialLoading, setIsInitialLoading] = useState(true) + const [manualLoadingId, setManualLoadingId] = useState(null) + + // VERBOSE: Initializing arrow state. In loop mode, these will mostly stay true + // unless the content is too small to scroll. + const [canScrollPrev, setCanScrollPrev] = useState(false) + const [canScrollNext, setCanScrollNext] = useState(false) + + const [emblaRef, emblaApi] = useEmblaCarousel({ + loop: true, + align: "start", + skipSnaps: false, + }) + + const markAsVerified = useCallback( + (id: string, chainId: number | null) => { + if (!userAddress) return + saveUserActivity(userAddress, id, true, chainId).catch(() => { + // Verbose: error handling + }) + }, + [userAddress] + ) + + const contracts = useMemo(() => { + if (!userAddress || !manualLoadingId) return [] + const set = ECOSYSTEM_SETS.find((s) => s.id === manualLoadingId) + if (!set) return [] + return set.contracts.map((c) => ({ + address: c.address, + abi: erc721Abi, + functionName: "balanceOf", + args: [userAddress], + chainId: c.chainId, + })) + }, [userAddress, manualLoadingId]) + + // Log what is being checked when verification runs + useEffect(() => { + if (!manualLoadingId || !userAddress) return + const set = ECOSYSTEM_SETS.find((s) => s.id === manualLoadingId) + if (!set) return + const chainName = (id: number) => CHAIN_NAMES[id] ?? `Chain ${id}` + console.log(`[Verify Assets] Checking "${set.name.replace(/\n/g, " ")}" for ${userAddress}:`) + set.contracts.forEach((c) => { + console.log(` → ${chainName(c.chainId)} | ${c.address} (${c.label})`) + }) + }, [manualLoadingId, userAddress]) + + const { data: blockchainData } = useReadContracts({ + contracts, + query: { enabled: isConnected && !!userAddress && !!manualLoadingId }, + }) + + useEffect(() => { + const cached: Record = {} + ECOSYSTEM_SETS.forEach((s) => { + if (localStorage.getItem(`verified_${s.id}`) === "true") cached[s.id] = true + }) + setVerifiedSets(cached) + + const timer = setTimeout(() => setIsInitialLoading(false), 1200) + return () => clearTimeout(timer) + }, []) + + useEffect(() => { + if (!isConnected) { + setVerifiedSets({}) + setFailedSets({}) + setManualLoadingId(null) + return + } + if (!userAddress) return + fetchUserActivity(userAddress) + .then((activities) => { + const fromApi: Record = {} + Object.entries(activities).forEach(([entity, active]) => { + if (active) fromApi[entity] = true + }) + if (Object.keys(fromApi).length > 0) { + setVerifiedSets((prev) => ({ ...prev, ...fromApi })) + Object.keys(fromApi).forEach((id) => localStorage.setItem(`verified_${id}`, "true")) + } + }) + .catch((e) => console.error("Fetch user activity failed:", e)) + }, [isConnected, userAddress]) + + useEffect(() => { + if (!blockchainData || !manualLoadingId) return + const results = blockchainData as { status: string; result?: unknown }[] + const set = ECOSYSTEM_SETS.find((s) => s.id === manualLoadingId) + const chainId = set?.contracts[0]?.chainId ?? null + + const hasAssets = results.some((res) => res.status === "success" && Number(res.result) > 0) + + if (hasAssets) { + setVerifiedSets((prev) => ({ ...prev, [manualLoadingId]: true })) + setFailedSets((prev) => { + const next = { ...prev } + delete next[manualLoadingId] + return next + }) + localStorage.setItem(`verified_${manualLoadingId}`, "true") + markAsVerified(manualLoadingId, chainId) + } else { + setFailedSets((prev) => ({ ...prev, [manualLoadingId]: true })) + setTimeout(() => { + setFailedSets((prev) => { + const next = { ...prev } + delete next[manualLoadingId!] + return next + }) + }, 3000) + } + setManualLoadingId(null) + }, [blockchainData, manualLoadingId, markAsVerified]) + + const handleVerify = (id: string) => { + setFailedSets((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + setManualLoadingId(id) + } + + // If we have 5 cards and the screen only fits 3, scrolling is active. + useEffect(() => { + if (!emblaApi) return + const updateScrollState = () => { + // In loop mode, these return true if there's enough content to scroll. + setCanScrollPrev(emblaApi.canScrollPrev()) + setCanScrollNext(emblaApi.canScrollNext()) + } + emblaApi.on("select", updateScrollState) + emblaApi.on("reInit", updateScrollState) + updateScrollState() + return () => { + emblaApi.off("select", updateScrollState) + emblaApi.off("reInit", updateScrollState) + } + }, [emblaApi]) + + const fitsContainer = !canScrollPrev && !canScrollNext + + return ( +
+