diff --git a/.vscode/settings.json b/.vscode/settings.json index 3dec4ed8..f182f3d7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,6 +24,7 @@ "graylisted", "hookform", "kubb", + "Leaderboard", "linktree", "mpdao", "NADABOT", diff --git a/README.md b/README.md index 3b1af91c..5aca47eb 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,10 @@ You can see original features and documentation +You can see original features + +Core contracts can be found at and documentation + ## Development ### Getting Started diff --git a/src/common/api/indexer/hooks.ts b/src/common/api/indexer/hooks.ts index bd16b632..0b97bee5 100644 --- a/src/common/api/indexer/hooks.ts +++ b/src/common/api/indexer/hooks.ts @@ -11,6 +11,7 @@ import { V1AccountsRetrieveParams, V1AccountsUpvotedListsRetrieveParams, V1DonateContractConfigRetrieveParams, + V1DonorsRetrieveParams, V1ListsRandomRegistrationRetrieveParams, V1ListsRegistrationsRetrieveParams, V1ListsRetrieveParams, @@ -27,6 +28,19 @@ export const useStats = () => { return { ...queryResult, data: queryResult.data?.data }; }; +/** + * +https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_donors_retrieve + */ + +export const useDonors = ({ ...params }: V1DonorsRetrieveParams) => { + const queryResult = swrHooks.useV1DonorsRetrieve( + params, + POTLOCK_REQUEST_CONFIG, + ); + return { ...queryResult, data: queryResult.data?.data.results }; +}; + /** * https://dev.potlock.io/api/schema/swagger-ui/#/v1/v1_donate_contract_config_retrieve */ diff --git a/src/common/assets/svgs/Trophy.tsx b/src/common/assets/svgs/Trophy.tsx new file mode 100644 index 00000000..63b32f47 --- /dev/null +++ b/src/common/assets/svgs/Trophy.tsx @@ -0,0 +1,17 @@ +const Trophy = (props: any) => { + return ( + + + + ); +}; + +export default Trophy; diff --git a/src/common/ui/components/atoms/button.tsx b/src/common/ui/components/atoms/button.tsx index 17bd5996..36fcc682 100644 --- a/src/common/ui/components/atoms/button.tsx +++ b/src/common/ui/components/atoms/button.tsx @@ -48,7 +48,7 @@ const buttonVariants = cva( ), "standard-outline": cn( - "bg-white hover:bg-[var(--neutral-50)", + "bg-white hover:bg-[var(--neutral-50) outline-none focus:shadow-[0px_0px_0px_1px_rgba(0,0,0,0.22)_inset,0px_-1px_0px_0px_rgba(15,15,15,0.15)_inset,0px_1px_2px_-0.5px_rgba(5,5,5,0.08)]", "shadow-[0px_0px_0px_1px_rgba(0,0,0,0.22)_inset,0px_-1px_0px_0px_rgba(15,15,15,0.15)_inset,0px_1px_2px_-0.5px_rgba(5,5,5,0.08)]", "disabled:text-[#c7c7c7] disabled:shadow-[0px_0px_0px_1px_rgba(15,15,15,0.15)_inset]", ), diff --git a/src/common/ui/components/filter-chip.tsx b/src/common/ui/components/filter-chip.tsx new file mode 100644 index 00000000..31aadaae --- /dev/null +++ b/src/common/ui/components/filter-chip.tsx @@ -0,0 +1,79 @@ +import * as React from "react"; + +import { Slot } from "@radix-ui/react-slot"; +import { type VariantProps, cva } from "class-variance-authority"; + +import { cn } from "../utils"; + +// TODO: add correct hover effects +const filterChipVariants = cva( + cn( + "flex text-sm leading-[157%] items-center justify-center text-[#292929] gap-2 font-medium whitespace-nowrap px-12px py-6px", + "no-underline cursor-pointer transition-all duration-200 ease-in-out w-fit rounded-md", + "border-none focus:shadow-button-focus disabled:cursor-not-allowed", + ), + + { + variants: { + variant: { + // Brand + "brand-filled": cn( + "bg-#fce9d5 shadow-amber outline-none translate-y-[-1.5px] text-#91321B font-bold border border-#f4b37d border-solid hover:translate-y-0 focus:shadow-#f4b37d", + "hover:shadow-[0px_0px_0px_1px_rgba(244, 179, 125, 1)_inset,0px_1px_1px_1px_rgba(252, 233, 213, 1)_inset,0px_0px_0px_2px_rgba(252, 233, 213, 1)_inset]", + "disabled:text-[#a6a6a6] disabled:shadow-[0px_0px_0px_1px_rgba(15,15,15,0.15)_inset] disabled:bg-[var(--neutral-100)]", + ), + + "brand-plain": cn( + "text-[color:var(--primary-600)] p-0 hover:text-[color:var(--Primary-400)]", + "disabled:text-[#a6a6a6] disabled:shadow-[0px_0px_0px_1px_rgba(15,15,15,0.15)_inset] disabled:bg-[var(--neutral-100)]", + ), + + "brand-outline": cn( + "bg-white hover:bg-[var(--neutral-50) outline-none focus:shadow-[0px_0px_0px_1px_rgba(0,0,0,0.22)_inset,0px_-1px_0px_0px_rgba(15,15,15,0.15)_inset,0px_1px_2px_-0.5px_rgba(5,5,5,0.08)]", + "shadow-[0px_0px_0px_1px_rgba(0,0,0,0.22)_inset,0px_-1px_0px_0px_rgba(15,15,15,0.15)_inset,0px_1px_2px_-0.5px_rgba(5,5,5,0.08)]", + "disabled:text-[#c7c7c7] disabled:shadow-[0px_0px_0px_1px_rgba(15,15,15,0.15)_inset]", + ), + }, + + size: { + default: "px-4 py-[9px]", + icon: "h-10 w-10", + }, + + font: { + default: "font-medium", + bold: "font-bold", + semibold: "font-semibold", + }, + }, + + defaultVariants: { + font: "default", + variant: "brand-filled", + size: "default", + }, + }, +); + +export interface FilterChipProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const FilterChip = React.forwardRef( + ({ className, variant, font, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); + +FilterChip.displayName = "FilterChip"; + +export { FilterChip, filterChipVariants }; diff --git a/src/common/ui/components/index.ts b/src/common/ui/components/index.ts index 391b745c..fddb1495 100644 --- a/src/common/ui/components/index.ts +++ b/src/common/ui/components/index.ts @@ -4,6 +4,7 @@ * See https://atomicdesign.bradfrost.com/chapter-2/ */ export * from "./dropdown-menu"; +export * from "./filter-chip"; export * from "./InfiniteScroll"; export * from "./popover"; export * from "./scroll-area"; diff --git a/src/modules/core/components/Nav.tsx b/src/modules/core/components/Nav.tsx index ce318e41..c16130e3 100644 --- a/src/modules/core/components/Nav.tsx +++ b/src/modules/core/components/Nav.tsx @@ -23,8 +23,7 @@ const links = [ }, { label: "Feed", url: routesPath.FEED, disabled: false }, - // { label: "Donors", url: routesPath.DONORS, disabled: false }, - + { label: "Donors", url: routesPath.DONORS, disabled: false }, { label: "Lists", url: routesPath.LIST, diff --git a/src/modules/donation/components/DonationLeaderboardEntry.tsx b/src/modules/donation/components/DonationLeaderboardEntry.tsx new file mode 100644 index 00000000..1eb104fe --- /dev/null +++ b/src/modules/donation/components/DonationLeaderboardEntry.tsx @@ -0,0 +1,61 @@ +import Image from "next/image"; + +import Trophy from "@/common/assets/svgs/Trophy"; + +export type DonationLeaderboardEntryProps = { + rank: number; + image: string; + name: string; + amount: number; + type: "donor" | "sponsor"; +}; + +export const DonationLeaderboardEntry: React.FC< + DonationLeaderboardEntryProps +> = ({ rank, image, name, amount, type }) => { + const bgClass = + rank === 1 + ? "bg-gradient-to-r from-orange-400 to-red-500 text-white" + : rank === 2 + ? "bg-gradient-to-r from-#F7F7F7 to-#DBDBDB" + : "bg-gradient-to-r from-#FCE9D5 to-#F8D3B0"; + + const rankText = ["1st", "2nd", "3rd"][rank - 1]; + const iconClass = + rank === 1 + ? "fill-yellow-400" + : rank === 2 + ? "fill-gray-400" + : "fill-red-800"; + + return ( +
+
+
+ +
+ {rankText} +
+
+ {name} +

+ {name} +

+

+ {amount}{" "} + + NEAR {type === "donor" ? "Donated" : "Sponsored"} + +

+
+
+ ); +}; diff --git a/src/modules/donation/index.ts b/src/modules/donation/index.ts index f37ec91e..f44711e8 100644 --- a/src/modules/donation/index.ts +++ b/src/modules/donation/index.ts @@ -1,6 +1,7 @@ export * from "./constants"; export * from "./components/breakdowns"; export * from "./components/buttons"; +export * from "./components/DonationLeaderboardEntry"; export * from "./components/DonationSybilWarning"; export * from "./hooks"; export * from "./models/schemas"; diff --git a/src/pages/donors/index.tsx b/src/pages/donors/index.tsx new file mode 100644 index 00000000..701d0533 --- /dev/null +++ b/src/pages/donors/index.tsx @@ -0,0 +1,648 @@ +import { useMemo, useState } from "react"; + +import { Lora } from "next/font/google"; +import Image from "next/image"; + +import { coingecko } from "@/common/api/coingecko"; +import { Account } from "@/common/api/indexer"; +import { useDonors } from "@/common/api/indexer/hooks"; +import { NearIcon } from "@/common/assets/svgs"; +import { daysAgo } from "@/common/lib"; +import { FilterChip, SearchBar, ToggleGroup } from "@/common/ui/components"; +import { AccountOption } from "@/modules/core"; +import { DonationLeaderboardEntry } from "@/modules/donation"; + +const lora = Lora({ + subsets: ["latin"], + variable: "--font-lora", + weight: ["400", "500", "600", "700"], +}); + +interface Participant { + rank: number; + image: string; + name: string; + amount: number; + amountUsd: number; +} + +interface Activity { + sender: string; + senderImage: string; + amount: number; + amountUsd: number; + currency: string; + receiver: string; + receiverImage: string; + timestamp: number; +} + +const topDonors: Participant[] = [ + { + rank: 1, + image: "https://picsum.photos/200/200/?blur", + name: "nearcollective.near", + amount: 731.25, + amountUsd: 0, + }, + { + rank: 2, + image: "https://picsum.photos/200/200/?blur", + name: "nf-payments.near", + amount: 731.25, + amountUsd: 0, + }, + { + rank: 3, + image: "https://picsum.photos/200/200/?blur", + name: "creatives.potlock.near", + amount: 731.25, + amountUsd: 0, + }, +]; + +const otherDonors: Participant[] = [ + { + rank: 4, + image: "https://picsum.photos/200/200/?blur", + name: "creativesportfolio.near", + amount: 2000, + amountUsd: 2000, + }, + { + rank: 5, + image: "https://picsum.photos/200/200/?blur", + name: "mike.near", + amount: 2000, + amountUsd: 2000, + }, + { + rank: 6, + image: "https://picsum.photos/200/200/?blur", + name: "mike.near", + amount: 2000, + amountUsd: 2000, + }, +]; + +const topSponsors: Participant[] = [ + { + rank: 1, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor1.near", + amount: 1000, + amountUsd: 1000, + }, + { + rank: 2, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor2.near", + amount: 900, + amountUsd: 900, + }, + { + rank: 3, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor3.near", + amount: 800, + amountUsd: 800, + }, + { + rank: 4, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor4.near", + amount: 800, + amountUsd: 800, + }, + { + rank: 5, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor5.near", + amount: 100, + amountUsd: 200, + }, + { + rank: 6, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor6.near", + amount: 10, + amountUsd: 10, + }, + { + rank: 7, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor7.near", + amount: 30, + amountUsd: 800, + }, + { + rank: 8, + image: "https://picsum.photos/200/200/?blur", + name: "sponsor8.near", + amount: 800, + amountUsd: 800, + }, +]; + +const ACTIVITY: Activity[] = [ + { + sender: "nearcollective.near", + senderImage: "https://picsum.photos/200/200/?blur", + amount: 1000, + amountUsd: 1000, + currency: "NEAR", + receiver: "creativesportfolio.near", + receiverImage: "https://picsum.photos/200/200/?blur", + timestamp: Date.now() - 1000 * 60 * 60 * 24 * 2, + }, + { + sender: "nf-payments.near", + senderImage: "https://picsum.photos/200/200/?blur", + amount: 1000, + amountUsd: 1000, + currency: "NEAR", + receiver: "mike.near", + receiverImage: "https://picsum.photos/200/200/?blur", + timestamp: Date.now() - 1000 * 60 * 60 * 24 * 2, + }, + { + sender: "creatives.potlock.near", + senderImage: "https://picsum.photos/200/200/?blur", + amount: 1000, + amountUsd: 1000, + currency: "NEAR", + receiver: "mike.near", + receiverImage: "https://picsum.photos/200/200/?blur", + timestamp: Date.now() - 1000 * 60 * 60 * 24 * 2, + }, + { + sender: "nearcollective.near", + senderImage: "https://picsum.photos/200/200/?blur", + amount: 1000, + amountUsd: 1000, + currency: "NEAR", + receiver: "creativesportfolio.near", + receiverImage: "https://picsum.photos/200/200/?blur", + timestamp: Date.now() - 1000 * 60 * 60 * 24 * 2, + }, + { + sender: "nf-payments.near", + senderImage: "https://picsum.photos/200/200/?blur", + amount: 1000, + amountUsd: 1000, + currency: "NEAR", + receiver: "mike.near", + receiverImage: "https://picsum.photos/200/200/?blur", + timestamp: Date.now() - 1000 * 60 * 60 * 24 * 2, + }, +]; + +// Define a type guard to check if an object is of type Account +function isAccount(obj: any): obj is Account { + return "total_donations_out_usd" in obj; +} + +export default function LeaderboardPage() { + const [searchTerm, setSearchTerm] = useState(""); + const [searchActivity, setSearchActivity] = useState(""); + const [timeFilter, setTimeFilter] = useState("All time"); + const [selectedTab, setSelectedTab] = useState< + "donors" | "sponsors" | "activities" + >("activities"); + const toggleTab = (tab: "donors" | "sponsors" | "activities") => { + setSelectedTab(tab); + }; + + const { data: donors } = useDonors({}); + + const sponsors: Participant[] = []; + const { data: priceOfOneNear } = coingecko.useOneNearUsdPrice(); + const price = priceOfOneNear ?? 5.0; + + console.log({ donors, priceOfOneNear }); + + const handleSearch = (participant: any) => { + if (!participant) return false; + + const searchLower = searchTerm.toLowerCase(); + + const idMatches = isAccount(participant) + ? participant.id?.toLowerCase().includes(searchLower) + : participant.name.includes(searchLower); + + const nameMatches = participant.near_social_profile_data?.name + ?.toLowerCase() + .includes(searchLower); + + return idMatches || nameMatches; + }; + + const renderLeaderboard = ( + participants: Participant[], + type: "donor" | "sponsor", + ) => { + const data = type === "donor" ? [...(donors || [])] : [...participants]; + + console.log("data now", data); + + return ( + <> +
+ setSearchTerm(e.target.value)} + /> +
+ {["All time", "1Y", "30D", "1W", "1D"].map((filter) => ( + setTimeFilter(filter)} + className="text-sm" + > + {filter} + + ))} +
+
+
+
+ {data + ?.sort((a, b) => { + const aAmount = isAccount(a) + ? a.total_donations_out_usd + : a.amountUsd; + const bAmount = isAccount(b) + ? b.total_donations_out_usd + : b.amountUsd; + return bAmount - aAmount; + }) + .slice(0, 3) + .map((participant, index) => { + const name = isAccount(participant) + ? (participant.near_social_profile_data?.name ?? + (participant.id?.length <= 20 + ? participant.id + : `${participant.id.substring(0, 16)}...${participant.id.substring(participant.id.length - 4)}`)) + : participant.name; + return ( + + ); + })} +
+
+ +
+ + + + + + + + + + + {data + ?.sort((a, b) => { + const aAmount = isAccount(a) ? a.total_donations_out_usd : 0; + const bAmount = isAccount(b) ? b.total_donations_out_usd : 0; + return bAmount - aAmount; + }) + .slice(3) + .filter(handleSearch) + .map((donor, index) => ( + + + + + + + ))} + +
+ Rank + + Projects + + Amount + + AMT (USD) +
+
+ + #{isAccount(donor) ? index + 1 : donor.rank} + + {(isAccount(donor) ? index + 1 : donor.rank) === 4 ? ( +
+ ) : ( +
+ )} +
+
+ + {/*
+
+ {participant.near_social_profile_data?.name ?? + participant.id} +
+
*/} +
+
+ + + {isAccount(donor) + ? (donor.total_donations_out_usd / price).toFixed(2) + : donor.amount} + +
+
+ ${" "} + {isAccount(donor) + ? donor.total_donations_out_usd + : donor.amountUsd} +
+
+
+ {data?.map((participant, index) => ( +
+
+
+ profile picture +
+
+
+
+
+
+ {isAccount(participant) + ? (participant?.near_social_profile_data?.name ?? + participant.id) + : participant.name} +
+
+
+
+ + #{index + 1} + + {index === 4 ? ( +
+ ) : ( +
+ )} +
+
+
+
+
+ + + {isAccount(participant) + ? participant.total_donations_out_usd / price + : participant.amount} + +
+
+ ~${" "} + {isAccount(participant) + ? participant.total_donations_out_usd + : participant.amountUsd} +
+
+
+
+ ))} +
+ + ); + }; + + const TABs = [ + { + name: "activities", + label: "All Activities", + count: 20000, + }, + { + name: "donors", + label: "Donor Leaderboard", + count: donors?.length || 0, + }, + { + name: "sponsors", + label: "Sponsor Leaderboard", + count: 68, + }, + ]; + + return ( +
+ +
+
+ {TABs.map((tab) => ( +
+ toggleTab(tab.name as "donors" | "sponsors" | "activities") + } + > + + {tab.label} + + + {tab.count} + +
+ ))} +
+
+
+
+
+
+ {selectedTab === "activities" ? ( +
+

+ All Activities +

+ <> +
+ setSearchTerm(e.target.value)} + /> +
+ {["All time", "1Y", "30D", "1W", "1D"].map((filter) => ( + setTimeFilter(filter)} + className="text-sm" + > + {filter} + + ))} +
+
+
+ {ACTIVITY.map((activity, index) => ( +
+
+
+ sender image +

+ {activity.sender} +

+
+
+
+ Donated +
+
+
+ {" "} + + {activity.amount} + +
+
+
to
+
+
+
+
+ receiver image +

+ {activity.receiver} +

+
+
+ {" "} + {daysAgo(activity.timestamp)} +
+
+
+ ))} +
+ +
+ ) : null} + {selectedTab === "donors" ? ( +
+

+ Donor Leaderboard +

+ {renderLeaderboard([...topDonors, ...otherDonors], "donor")} +
+ ) : null} + {selectedTab === "sponsors" ? ( +
+

+ Sponsor Leaderboard +

+ {renderLeaderboard(topSponsors, "sponsor")} +
+ ) : null} +
+
+
+
+ ); +}