diff --git a/.env.example b/.env.example index 8a7f53d7ed8..eec7688be81 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,3 @@ ANALYZE=false # Use mock data for development. Set to "false" to use live data but you must have the # environment variables set to make api requests USE_MOCK_DATA=true - -# Google Sheet ID for torch holders -GOOGLE_SHEET_ID_TORCH_HOLDERS= \ No newline at end of file diff --git a/app/[locale]/10years/_components/CountDown/index.tsx b/app/[locale]/10years/_components/CountDown/index.tsx index 550ebb1308e..14d54daa410 100644 --- a/app/[locale]/10years/_components/CountDown/index.tsx +++ b/app/[locale]/10years/_components/CountDown/index.tsx @@ -10,12 +10,18 @@ interface CountDownProps { className?: string timeLeftLabels: TimeLeftLabels expiredLabel: string + dateTime: string + hideZeroUnits?: boolean + onExpired?: () => void } const CountDown = ({ className, timeLeftLabels, expiredLabel, + dateTime, + hideZeroUnits = false, + onExpired, }: CountDownProps) => { const [timeLeft, setTimeLeft] = useState({ days: 0, @@ -26,7 +32,7 @@ const CountDown = ({ const [isExpired, setIsExpired] = useState(false) useEffect(() => { - const targetDate = new Date("2025-07-30T15:44:00Z") + const targetDate = new Date(dateTime) const calculateTimeLeft = () => { const now = new Date() @@ -41,7 +47,10 @@ const CountDown = ({ seconds: Math.floor((difference / 1000) % 60), }) } else { - setIsExpired(true) + if (!isExpired) { + setIsExpired(true) + onExpired?.() + } } } @@ -51,74 +60,81 @@ const CountDown = ({ const timer = setInterval(calculateTimeLeft, 1000) return () => clearInterval(timer) - }, []) + }, [isExpired, onExpired, dateTime]) if (isExpired) { return
{expiredLabel}
} return ( -
-
-
- {timeLeft.days} +
+ {(!hideZeroUnits || timeLeft.days > 0) && ( +
+
{timeLeft.days}
+
+ {timeLeft.days === 1 + ? timeLeftLabels.days.singular + : timeLeftLabels.days.plural} +
-
- {timeLeft.days === 1 - ? timeLeftLabels.days.singular - : timeLeftLabels.days.plural} + )} + {(!hideZeroUnits || timeLeft.days > 0 || timeLeft.hours > 0) && ( +
+
{timeLeft.hours}
+
+ {timeLeft.hours === 1 + ? timeLeftLabels.hours.singular + : timeLeftLabels.hours.plural} +
-
-
-
- {timeLeft.hours} + )} + {(!hideZeroUnits || + timeLeft.days > 0 || + timeLeft.hours > 0 || + timeLeft.minutes > 0) && ( +
+
{timeLeft.minutes}
+
+ {timeLeft.minutes === 1 + ? timeLeftLabels.minutes.singular + : timeLeftLabels.minutes.plural} +
-
- {timeLeft.hours === 1 - ? timeLeftLabels.hours.singular - : timeLeftLabels.hours.plural} + )} + {(!hideZeroUnits || + timeLeft.days > 0 || + timeLeft.hours > 0 || + timeLeft.minutes > 0 || + timeLeft.seconds > 0) && ( +
+
{timeLeft.seconds}
+
+ {timeLeft.seconds === 1 + ? timeLeftLabels.seconds.singular + : timeLeftLabels.seconds.plural} +
-
-
-
- {timeLeft.minutes} -
-
- {timeLeft.minutes === 1 - ? timeLeftLabels.minutes.singular - : timeLeftLabels.minutes.plural} -
-
-
-
- {timeLeft.seconds} -
-
- {timeLeft.seconds === 1 - ? timeLeftLabels.seconds.singular - : timeLeftLabels.seconds.plural} -
-
+ )}
) } diff --git a/app/[locale]/10years/_components/NFTMintCard/Connected.tsx b/app/[locale]/10years/_components/NFTMintCard/Connected.tsx new file mode 100644 index 00000000000..d910e58af21 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/Connected.tsx @@ -0,0 +1,77 @@ +import { Address } from "viem" +import { useDisconnect, useSwitchChain } from "wagmi" + +import { Avatar } from "@/components/ui/avatar" +import { Button } from "@/components/ui/buttons/Button" + +import { useNetworkContract } from "@/hooks/useNetworkContract" +import { + formatAddress, + getAddressEtherscanUrl, + getBlockieImage, +} from "@/lib/torch" + +export default function Connected({ + address, + ensName, +}: { + address: Address + ensName?: string | null +}) { + const { disconnect } = useDisconnect() + const { isSupportedNetwork, networkName, chainId } = useNetworkContract() + const { switchChain, isPending } = useSwitchChain() + + const handleSwitchNetwork = () => { + switchChain({ chainId }) + } + + return ( +
+ {/* Wallet Info */} +
+ +
{ensName || formatAddress(address)}
+
+ + {/* Network Status */} +
+ {isSupportedNetwork ? ( +
+
+ Connected to {networkName} +
+ ) : ( +
+
+
+ Unsupported Network +
+ +
+ )} +
+ + {/* Disconnect Button */} + +
+ ) +} diff --git a/app/[locale]/10years/_components/NFTMintCard/Connection.tsx b/app/[locale]/10years/_components/NFTMintCard/Connection.tsx new file mode 100644 index 00000000000..2229608438d --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/Connection.tsx @@ -0,0 +1,14 @@ +import { useAccount } from "wagmi" + +import MintConnect from "./views/MintConnect" +import Prechecks from "./Prechecks" + +export default function Connection() { + const { address, isConnected } = useAccount() + + if (!isConnected || !address) { + return + } + + return +} diff --git a/app/[locale]/10years/_components/NFTMintCard/GasFeeInformation.tsx b/app/[locale]/10years/_components/NFTMintCard/GasFeeInformation.tsx new file mode 100644 index 00000000000..76499cd04da --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/GasFeeInformation.tsx @@ -0,0 +1,26 @@ +import { Info } from "lucide-react" + +import { + Alert, + AlertContent, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert" + +const GasFeeInformation = () => { + return ( + + + + About Network Fees + + While the NFT is free, you'll need to pay Ethereum network fees + to complete the transaction. Network fees vary throughout the day - + consider waiting for lower network fees periods to save on costs. + + + + ) +} + +export default GasFeeInformation diff --git a/app/[locale]/10years/_components/NFTMintCard/GasPriceDisplay.tsx b/app/[locale]/10years/_components/NFTMintCard/GasPriceDisplay.tsx new file mode 100644 index 00000000000..1622d8ae59a --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/GasPriceDisplay.tsx @@ -0,0 +1,95 @@ +"use client" + +import { cn } from "@/lib/utils/cn" + +import { type GasPriceLevel, useGasPrice } from "@/hooks/useGasPrice" + +const getGasLevelConfig = (level: GasPriceLevel | null) => { + switch (level) { + case "low": + return { + color: "text-green-600", + bgColor: "bg-green-100", + indicator: "bg-green-500", + label: "Low", + description: "Good time to mint!", + } + case "moderate": + return { + color: "text-yellow-600", + bgColor: "bg-yellow-100", + indicator: "bg-yellow-500", + label: "Moderate", + description: "Normal gas prices", + } + case "high": + return { + color: "text-orange-600", + bgColor: "bg-orange-100", + indicator: "bg-orange-500", + label: "High", + description: "Consider waiting for lower gas", + } + case "very_high": + return { + color: "text-red-600", + bgColor: "bg-red-100", + indicator: "bg-red-500", + label: "Very High", + description: "Wait for lower gas prices", + } + default: + return { + color: "text-gray-600", + bgColor: "bg-gray-100", + indicator: "bg-gray-400", + label: "Loading", + description: "Fetching gas prices...", + } + } +} + +interface GasPriceDisplayProps { + className?: string +} + +const GasPriceDisplay = ({ className }: GasPriceDisplayProps) => { + const { error, gasLevel } = useGasPrice() + + const loading = false + + const config = getGasLevelConfig(gasLevel) + + if (error) { + return ( +
+

Unable to fetch network fees

+
+ ) + } + + return ( +
+ {/* Gas Price Display */} +
+
+
+ Current Network Fee: +
+
+ {loading ? ( +
+ ) : ( +
+ + {config.label} + +
+ )} +
+
+
+ ) +} + +export default GasPriceDisplay diff --git a/app/[locale]/10years/_components/NFTMintCard/Mint.tsx b/app/[locale]/10years/_components/NFTMintCard/Mint.tsx new file mode 100644 index 00000000000..d5c75ebaf95 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/Mint.tsx @@ -0,0 +1,112 @@ +import { useEffect } from "react" +import { Address } from "viem" +import { + useEnsName, + useWaitForTransactionReceipt, + useWriteContract, +} from "wagmi" + +import { Button } from "@/components/ui/buttons/Button" + +import MintError from "./views/MintError" +import MintSuccess from "./views/MintSuccess" +import Connected from "./Connected" +import GasPriceDisplay from "./GasPriceDisplay" + +import { useNetworkContract } from "@/hooks/useNetworkContract" +import { getErrorMessage } from "@/lib/torch" + +interface MintProps { + address: Address + onSuccess?: (txHash: string) => void +} + +export default function Mint({ address, onSuccess }: MintProps) { + const { data: ensName } = useEnsName({ address }) + const { contractData, isSupportedNetwork } = useNetworkContract() + + const { + writeContract: mint, + isPending: isMinting, + data: hash, + error: writeError, + reset: resetWriteContract, + } = useWriteContract() + + const { + isSuccess: isConfirmed, + isLoading: isReceiptLoading, + error: receiptError, + } = useWaitForTransactionReceipt({ + hash, + query: { + refetchOnWindowFocus: false, + staleTime: 60 * 60 * 1000, + enabled: !!hash, + }, + }) + + useEffect(() => { + if (isConfirmed && hash && onSuccess) { + onSuccess(hash) + } + }, [isConfirmed, hash, onSuccess]) + + const handleMintClick = async () => { + mint({ + address: contractData.address as `0x${string}`, + abi: contractData.abi, + functionName: "mint", + }) + } + + const resetMintState = () => { + resetWriteContract() + } + + if (isConfirmed && hash) { + return + } + + if (writeError) { + const errorMessage = getErrorMessage(writeError) + return ( + + ) + } + + if (receiptError) { + const errorMessage = getErrorMessage(receiptError) + return ( + + ) + } + + return ( +
+ + + {isSupportedNetwork && ( + <> + + + )} + + +
+ ) +} diff --git a/app/[locale]/10years/_components/NFTMintCard/Prechecks.tsx b/app/[locale]/10years/_components/NFTMintCard/Prechecks.tsx new file mode 100644 index 00000000000..cb496f516e7 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/Prechecks.tsx @@ -0,0 +1,42 @@ +import { useState } from "react" +import { Address } from "viem" +import { useReadContract } from "wagmi" + +import MintAlreadyMinted from "./views/MintAlreadyMinted" +import MintSuccess from "./views/MintSuccess" +import Mint from "./Mint" + +import { useNetworkContract } from "@/hooks/useNetworkContract" + +export default function Prechecks({ address }: { address: Address }) { + const { contractData, isSupportedNetwork } = useNetworkContract() + const [successTxHash, setSuccessTxHash] = useState(null) + + const { data: hasMinted, error: hasMintedError } = useReadContract({ + address: contractData.address, + abi: contractData.abi, + functionName: "hasMinted", + args: [address], + query: { + enabled: isSupportedNetwork, + }, + }) + + const handleMintSuccess = (txHash: string) => { + setSuccessTxHash(txHash) + } + + if (hasMintedError) { + return
Error checking minted
+ } + + if (successTxHash) { + return + } + + if (hasMinted) { + return + } + + return +} diff --git a/app/[locale]/10years/_components/NFTMintCard/index.tsx b/app/[locale]/10years/_components/NFTMintCard/index.tsx new file mode 100644 index 00000000000..4cdd1aea9c5 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/index.tsx @@ -0,0 +1,130 @@ +"use client" + +import { useMemo, useState } from "react" + +import { Alert, AlertContent, AlertTitle } from "@/components/ui/alert" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" + +import { cn } from "@/lib/utils/cn" + +import CountDown from "../CountDown" + +import Connection from "./Connection" +import GasFeeInformation from "./GasFeeInformation" + +import Curved10YearsText from "@/public/images/10-year-anniversary/10y-curved-heading.svg" + +interface NFTMintCardProps { + className?: string +} + +const endTimestamp = process.env.NEXT_PUBLIC_MINT_TIMESTAMP_END + +if (!endTimestamp) { + throw new Error("NEXT_PUBLIC_MINT_TIMESTAMP_END is not set") +} + +const NFTMintCard = ({ className }: NFTMintCardProps) => { + const endDateTime = useMemo(() => { + return new Date(Number(endTimestamp) * 1000).toISOString() + }, []) + + const [isExpired, setIsExpired] = useState(false) + + const handleExpired = () => { + setIsExpired(true) + } + + return ( + <> + + +
+ {/* Torch/flame video */} +
+
+
+
+ + {/* Curved text */} + +
+ + Mint the moment +
+ + +

+ Celebrate a decade of decentralization with a free, limited-time + 10th anniversary NFT. Mint yours before time runs out. +

+ + {isExpired ? ( + + + + The claim period has ended + +

+ Thank you all for joining the celebration +

+
+
+ ) : ( + <> +
+ +

+ Time remaining to mint +

+
+ + + + + + )} +
+
+ + ) +} + +export default NFTMintCard diff --git a/app/[locale]/10years/_components/NFTMintCard/views/MintAlreadyMinted.tsx b/app/[locale]/10years/_components/NFTMintCard/views/MintAlreadyMinted.tsx new file mode 100644 index 00000000000..8de1889d247 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/views/MintAlreadyMinted.tsx @@ -0,0 +1,26 @@ +import { Alert, AlertContent, AlertDescription } from "@/components/ui/alert" +import { BaseLink } from "@/components/ui/Link" + +export default function MintAlreadyMinted() { + const tweetText = encodeURIComponent( + "🎉 I have my free 10th-Anniversary collectible NFT from ethereum.org 🔷 Celebrating a decade of open, decentralized innovation. Join me 👉 https://ethereum.org/en/10years/ #Ethereum10" + ) + const tweetUrl = `https://twitter.com/intent/tweet?text=${tweetText}` + + return ( +
+ + + + Already minted + + + + + Share the celebration +
+ ) +} diff --git a/app/[locale]/10years/_components/NFTMintCard/views/MintConnect.tsx b/app/[locale]/10years/_components/NFTMintCard/views/MintConnect.tsx new file mode 100644 index 00000000000..f73065edbd4 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/views/MintConnect.tsx @@ -0,0 +1,50 @@ +import { useState } from "react" +import { ConnectButton } from "@rainbow-me/rainbowkit" + +import { Button } from "@/components/ui/buttons/Button" +import Checkbox from "@/components/ui/checkbox" +import InlineLink from "@/components/ui/Link" + +export default function MintConnect() { + const [acceptedTerms, setAcceptedTerms] = useState(false) + + return ( +
+ + {({ account, chain, openConnectModal, mounted }) => { + const ready = mounted + if (!ready) return null + + if (account && chain) { + return + } + + return ( + + ) + }} + + +
+ +
+
+ ) +} diff --git a/app/[locale]/10years/_components/NFTMintCard/views/MintError.tsx b/app/[locale]/10years/_components/NFTMintCard/views/MintError.tsx new file mode 100644 index 00000000000..b2991528184 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/views/MintError.tsx @@ -0,0 +1,42 @@ +import { Alert, AlertContent, AlertDescription } from "@/components/ui/alert" +import { Button } from "@/components/ui/buttons/Button" +import { BaseLink } from "@/components/ui/Link" + +import { getTxEtherscanUrl } from "@/lib/torch" + +export default function MintError({ + errorMessage, + onTryAgain, + hash, +}: { + errorMessage: string + onTryAgain: () => void + hash?: string +}) { + return ( +
+ + + +

{errorMessage}

+ + {hash && ( +
+ + View transaction on Etherscan + +
+ )} +
+
+
+ + +
+ ) +} diff --git a/app/[locale]/10years/_components/NFTMintCard/views/MintSuccess.tsx b/app/[locale]/10years/_components/NFTMintCard/views/MintSuccess.tsx new file mode 100644 index 00000000000..dde859367f0 --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCard/views/MintSuccess.tsx @@ -0,0 +1,78 @@ +import { useEffect } from "react" +import confetti from "canvas-confetti" + +import { + Alert, + AlertContent, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert" +import { BaseLink } from "@/components/ui/Link" + +import { getTxEtherscanUrl } from "@/lib/torch" + +export default function MintSuccess({ txHash }: { txHash?: string }) { + const tweetText = encodeURIComponent( + "🎉 I just claimed my free 10th-Anniversary collectible NFT from ethereum.org 🔷 Celebrating a decade of open, decentralized innovation. Join me 👉 https://ethereum.org/en/10years/ #Ethereum10" + ) + const tweetUrl = `https://twitter.com/intent/tweet?text=${tweetText}` + + const triggerConfetti = () => { + const duration = 5000 + const animationEnd = Date.now() + duration + const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 } + + const randomInRange = (min: number, max: number) => + Math.random() * (max - min) + min + + const interval = window.setInterval(() => { + const timeLeft = animationEnd - Date.now() + + if (timeLeft <= 0) { + return clearInterval(interval) + } + + const particleCount = 50 * (timeLeft / duration) + + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }, + }) + confetti({ + ...defaults, + particleCount, + origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }, + }) + }, 250) + } + + useEffect(() => { + triggerConfetti() + }, []) + + return ( +
+ + + Minting successful! + {txHash && ( + + + View transaction on Etherscan + + + )} + + + + Share the celebration +
+ ) +} diff --git a/app/[locale]/10years/_components/NFTMintCardWrapper.tsx b/app/[locale]/10years/_components/NFTMintCardWrapper.tsx new file mode 100644 index 00000000000..e28c08cfedd --- /dev/null +++ b/app/[locale]/10years/_components/NFTMintCardWrapper.tsx @@ -0,0 +1,40 @@ +"use client" + +import dynamic from "next/dynamic" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { Skeleton } from "@/components/ui/skeleton" + +// Dynamically import Wagmi/RainbowKit components +const WalletProviders = dynamic(() => import("@/components/WalletProviders"), { + ssr: false, + loading: () => ( + + ), +}) + +const NFTMintCard = dynamic(() => import("./NFTMintCard"), { + ssr: false, + loading: () => ( + + ), +}) + +const queryClient = new QueryClient() + +interface NFTMintCardWrapperProps { + className?: string + locale?: string +} + +const NFTMintCardWrapper = ({ className, locale }: NFTMintCardWrapperProps) => { + return ( + + + + + + ) +} + +export default NFTMintCardWrapper diff --git a/app/[locale]/10years/_components/TenYearHomeBanner.tsx b/app/[locale]/10years/_components/TenYearHomeBanner.tsx index 354442d3525..1e4dd056c06 100644 --- a/app/[locale]/10years/_components/TenYearHomeBanner.tsx +++ b/app/[locale]/10years/_components/TenYearHomeBanner.tsx @@ -52,6 +52,7 @@ const TenYearHomeBanner = async () => {
{/* CLIENT SIDE, lazy loaded */} = ({ >
- + + {twitter ? ( + + + {name[0]} + + ) : ( + <> + + {name[0]} + + )} +
{isCurrentHolder && ( diff --git a/app/[locale]/10years/_components/utils/nftMintDate.ts b/app/[locale]/10years/_components/utils/nftMintDate.ts new file mode 100644 index 00000000000..80bd36df798 --- /dev/null +++ b/app/[locale]/10years/_components/utils/nftMintDate.ts @@ -0,0 +1,22 @@ +/** + * Checks if the NFT mint card should be displayed based on the environment variable + * NEXT_PUBLIC_MINT_TIMESTAMP_START (timestamp in seconds) + */ +export const shouldShowNFTMintCard = (): boolean => { + const mintTimestamp = process.env.NEXT_PUBLIC_MINT_TIMESTAMP_START + + if (!mintTimestamp) { + return false + } + + try { + const mintDate = new Date(Number(mintTimestamp) * 1000) + const now = new Date() + + // Check if the mint date has passed (or is current) + return now >= mintDate + } catch (error) { + console.error("Invalid NFT_MINT_DATE format:", mintTimestamp) + return false + } +} diff --git a/app/[locale]/10years/page.tsx b/app/[locale]/10years/page.tsx index 23ffd6722a4..53e195fe55a 100644 --- a/app/[locale]/10years/page.tsx +++ b/app/[locale]/10years/page.tsx @@ -15,12 +15,16 @@ import Translation from "@/components/Translation" import { ButtonLink } from "@/components/ui/buttons/Button" import { LinkBox, LinkOverlay } from "@/components/ui/link-box" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import YouTube from "@/components/YouTube" import { cn } from "@/lib/utils/cn" import { dataLoader } from "@/lib/utils/data/dataLoader" import { getMetadata } from "@/lib/utils/metadata" import { getRequiredNamespacesForPage } from "@/lib/utils/translations" +// Import static torch holders data +import torchHoldersData from "@/data/torchHolders.json" + import { BASE_TIME_UNIT } from "@/lib/constants" import Curved10YearsText from "./_components/10y.svg" @@ -29,7 +33,7 @@ import CountDown from "./_components/CountDown/lazy" import CurrentTorchHolderCard from "./_components/CurrentTorchHolderCard" import { adoptionStyles } from "./_components/data" import InnovationSwiper from "./_components/InnovationSwiper/lazy" -import TenYearGlobe from "./_components/TenYearGlobe/lazy" +import NFTMintCardWrapper from "./_components/NFTMintCardWrapper" import TenYearHero from "./_components/TenYearHero" import TorchHistorySwiper from "./_components/TorchHistorySwiper/lazy" import Stories from "./_components/UserStories/lazy" @@ -39,17 +43,18 @@ import { getTimeUnitTranslations, parseStoryDates, } from "./_components/utils" +import { shouldShowNFTMintCard } from "./_components/utils/nftMintDate" import { routing } from "@/i18n/routing" import { fetch10YearEvents } from "@/lib/api/fetch10YearEvents" import { fetch10YearStories } from "@/lib/api/fetch10YearStories" -import { fetchTorchHolders } from "@/lib/api/fetchTorchHolders" import { getCurrentHolder, getHolderEvents, getTransferEvents, isAddressFiltered, isTorchBurned, + type TorchHolder, } from "@/lib/torch" import TenYearLogo from "@/public/images/10-year-anniversary/10-year-logo.png" @@ -60,7 +65,6 @@ const loadData = dataLoader( [ ["fetched10YearEvents", fetch10YearEvents], ["fetched10YearStories", fetch10YearStories], - ["fetchedTorchHolders", fetchTorchHolders], ], REVALIDATE_TIME * 1000 ) @@ -72,8 +76,9 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => { setRequestLocale(locale) - const [fetched10YearEvents, fetched10YearStories, allTorchHolders] = - await loadData() + const [fetched10YearEvents, fetched10YearStories] = await loadData() + + const allTorchHolders: TorchHolder[] = torchHoldersData as TorchHolder[] const stories = parseStoryDates(fetched10YearStories, locale) @@ -121,19 +126,28 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => { ) const currentHolder = getCurrentHolder(torchHolders) + const showNFTMint = shouldShowNFTMintCard() return ( -
- -
+ {!showNFTMint && ( +
+ +
+ )} -
+

@@ -146,38 +160,30 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => {

{t("page-10-year-hero-tagline")}

-
- +
+ {showNFTMint ? ( + + ) : ( + + )}
-
-
-

- {t("page-10-year-join-party-title")} -

-

- {t("page-10-year-join-party-description")} -

-
-
- {/* CLIENT SIDE, lazy loaded */} - - region.events.map((event) => ({ - ...event, - lat: Number(event.lat), - lng: Number(event.lng), - })) - )} - /> +
+
+

Join the livestream

+
@@ -273,21 +279,6 @@ const Page = async ({ params }: { params: Promise<{ locale: Lang }> }) => {
-
-
-

{t("page-10-year-events-description-1")}

-

{t("page-10-year-events-description-2")}

-
-
-

- {t("page-10-year-host-event-title")} -

-

{t("page-10-year-host-event-description")}

- - {t("page-10-year-host-event-cta")} - -
-
({ export async function GET() { // Preview mode: Show menu with original default - if (IS_PREVIEW_DEPLOY) return NextResponse.json(getPreviewConfig()) + if (!IS_PROD || IS_PREVIEW_DEPLOY) + return NextResponse.json(getPreviewConfig()) try { const matomoUrl = process.env.NEXT_PUBLIC_MATOMO_URL diff --git a/next.config.js b/next.config.js index 1182cd32334..5c39cb7693e 100644 --- a/next.config.js +++ b/next.config.js @@ -93,10 +93,6 @@ module.exports = (phase, { defaultConfig }) => { protocol: "https", hostname: "coin-images.coingecko.com", }, - { - protocol: "https", - hostname: "unavatar.io", - }, ], }, async headers() { diff --git a/package.json b/package.json index ff0bee03bd5..3c5d02889af 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,10 @@ "@socialgouv/matomo-next": "^1.8.0", "@tanstack/react-query": "^5.66.7", "@tanstack/react-table": "^8.19.3", + "@types/canvas-confetti": "^1.9.0", "@types/three": "^0.177.0", "@wagmi/core": "^2.17.3", + "canvas-confetti": "^1.9.3", "chart.js": "^4.4.2", "chartjs-plugin-datalabels": "^2.2.0", "class-variance-authority": "^0.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d39391527a..1a73147f708 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,12 +86,18 @@ importers: '@tanstack/react-table': specifier: ^8.19.3 version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/three': specifier: ^0.177.0 version: 0.177.0 '@wagmi/core': specifier: ^2.17.3 version: 2.17.3(@tanstack/query-core@5.80.2)(@types/react@18.2.57)(react@18.3.1)(typescript@5.8.3)(use-sync-external-store@1.4.0(react@18.3.1))(viem@2.30.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.22.4)) + canvas-confetti: + specifier: ^1.9.3 + version: 1.9.3 chart.js: specifier: ^4.4.2 version: 4.4.9 @@ -2878,6 +2884,9 @@ packages: '@types/babel__traverse@7.20.7': resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -3890,6 +3899,9 @@ packages: caniuse-lite@1.0.30001723: resolution: {integrity: sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==} + canvas-confetti@1.9.3: + resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==} + case-sensitive-paths-webpack-plugin@2.4.0: resolution: {integrity: sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==} engines: {node: '>=4'} @@ -11621,6 +11633,8 @@ snapshots: dependencies: '@babel/types': 7.27.3 + '@types/canvas-confetti@1.9.0': {} + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -13241,6 +13255,8 @@ snapshots: caniuse-lite@1.0.30001723: {} + canvas-confetti@1.9.3: {} + case-sensitive-paths-webpack-plugin@2.4.0: {} ccount@2.0.1: {} diff --git a/public/content/10years/terms-and-conditions/index.md b/public/content/10years/terms-and-conditions/index.md new file mode 100644 index 00000000000..13d2d214c78 --- /dev/null +++ b/public/content/10years/terms-and-conditions/index.md @@ -0,0 +1,65 @@ +--- +title: Ethereum 10-Year Anniversary NFT Mint Terms & Conditions +lang: en +hideEditButton: true +--- + +# Commemorative NFT minting terms {#commemorative-nft-minting-terms} + +20 June 2025 + +**10 YEARS OF ETHEREUM TORCH MINTING TERMS & CONDITIONS** + +**PLEASE READ THESE TERMS & CONDITIONS BEFORE MINTING THE 10 YEARS OF ETHEREUM TORCH** + +These terms and conditions constitute a binding agreement (the “**Agreement**”) between you (“**You**”) and the Ethereum Foundation, a Swiss foundation registered in Zug, Switzerland (the “**EF**”), governing Your minting and use of the non-fungible token known as the **10 Years of Ethereum Torch** (the “**NFT**”). By initiating the minting transaction, You acknowledge that You have read, understood, and agree to be bound by this Agreement. If You do not agree, do not proceed with minting. + +## 1. Nature of the NFT {#nature-of-the-nft} + +1. **Commemorative Purpose**. The NFT is issued solely to commemorate the ten-year anniversary of the Ethereum Genesis Block. It confers no ownership interest, financial right, expectation of profit, reward, dividend, governance right, utility, or any other right of any kind. + +2. **No Consideration**. The NFT is minted without charge; You are responsible only for the network transaction gas fees required to execute the minting transaction. EF receives no payment, royalty, or other consideration from Your mint. + +## 2. Intellectual property {#intellectual-property} + +1. **CC BY 4.0 Licence**. The artwork embodied in or associated with the NFT is licensed to the public under the [Creative Commons CC BY 4.0 International Licence](https://creativecommons.org/licenses/by/4.0/). + +2. **No Ownership Rights Conferred**. Minting, holding, or transferring the NFT does not transfer or confer any ownership right, title, or interest in or to the artwork or any other intellectual property of EF. + +## 3. Representations and warranties {#representations-and-warranties} + +You represent, warrant, and covenant that: + +1) You are at least eighteen (18) years old and have legal capacity to enter into this Agreement; + +2) You are not: (i) the subject of any economic or trade sanctions imposed or administered by Switzerland, the United States (including the OFAC SDN List), the European Union, the United Kingdom, the United Nations, or any other similar regime; and/or (ii) located, organised, or resident in a comprehensively sanctioned jurisdiction (currently North Korea Crimea, Iran, Syria, Cuba, Donetsk, or Luhansk); + +3) You are minting the NFT solely for commemorative and personal purposes and not as an investment or with an expectation of profit; and + +4) You are not acting on behalf of, or for the benefit of, any person or entity that fails to satisfy the foregoing. + +## 4. Disclaimers {#disclaimers} + +THE NFT AND ANY RELATED MATERIALS ARE PROVIDED “AS IS” AND “AS AVAILABLE”, WITHOUT WARRANTIES OF ANY KIND, WHETHER EXPRESS, IMPLIED, OR STATUTORY, INCLUDING WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR QUIET ENJOYMENT. EF DOES NOT WARRANT THAT THE NFT OR THE MINTING PROCESS WILL BE ERROR-FREE OR UNINTERRUPTED. + +## 5. Limitation of liability {#limitation-of-liability} + +To the maximum extent permitted by applicable law, EF, its directors, officers, employees, contractors, and agents shall not be liable to You for any indirect, incidental, consequential, special, exemplary, or punitive damages, or for any loss of profits, data, or goodwill, arising out of or related to the NFT or this Agreement, whether based in contract, tort, strict liability, or otherwise. EF’s aggregate liability to You for any direct damages shall not exceed one USD. + +## 6. Termination {#termination} + +EF may terminate this Agreement at any time. EF reserves the right to suspend or terminate minting, or to take reasonable remedial action (including nullification of the NFT) where required by law or regulation. + +## 7. Governing law and jurisdiction {#governing-law-and-jurisdiction} + +Any dispute, controversy, or claim arising out of or relating to this Agreement, including the validity, invalidity, breach, or termination thereof, shall be resolved by arbitration in accordance with the Swiss Rules of International Arbitration of the Swiss Chambers’ Arbitration Institution in force on the date on which the Notice of Arbitration is submitted in accordance with these Rules. The number of arbitrators shall be one. The seat of the arbitration shall be Zurich unless the parties agree on a different seat. The arbitral proceedings shall be conducted in English. + +## 8. Miscellaneous {#miscellaneous} + +1. **Entire Agreement**. This Agreement constitutes the entire agreement between You and EF with respect to the NFT and supersede all prior understandings. + +2. **Severability**. If any provision is held invalid or unenforceable, the remaining provisions shall remain in full force and effect. + +3. **No Waiver**. Failure or delay by EF to exercise any right shall not operate as a waiver thereof. + +4. **Assignment**. You may not assign or transfer Your rights or obligations under this Agreement without EF’s prior written consent. \ No newline at end of file diff --git a/public/images/10-year-anniversary/10y-cover.png b/public/images/10-year-anniversary/10y-cover.png new file mode 100644 index 00000000000..e62dcaa454b Binary files /dev/null and b/public/images/10-year-anniversary/10y-cover.png differ diff --git a/public/images/10-year-anniversary/10y-curved-heading.svg b/public/images/10-year-anniversary/10y-curved-heading.svg new file mode 100644 index 00000000000..d32d71ead92 --- /dev/null +++ b/public/images/10-year-anniversary/10y-curved-heading.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/10-year-anniversary/torchbearers/0x0c004944e16e9065Da1c7dB49F9964E2a3ac8892.jpg b/public/images/10-year-anniversary/torchbearers/0x0c004944e16e9065Da1c7dB49F9964E2a3ac8892.jpg new file mode 100644 index 00000000000..0a03b820a43 Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0x0c004944e16e9065Da1c7dB49F9964E2a3ac8892.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0x11adBC1B3fd5cb5F29B0052b4AfFe725645b5e4C.jpg b/public/images/10-year-anniversary/torchbearers/0x11adBC1B3fd5cb5F29B0052b4AfFe725645b5e4C.jpg new file mode 100644 index 00000000000..1b15a7da6a3 Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0x11adBC1B3fd5cb5F29B0052b4AfFe725645b5e4C.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0x36ACC9E5248f33B030d3eA3465AC1f99E55868Ec.jpg b/public/images/10-year-anniversary/torchbearers/0x36ACC9E5248f33B030d3eA3465AC1f99E55868Ec.jpg new file mode 100644 index 00000000000..9b9ea20c6f0 Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0x36ACC9E5248f33B030d3eA3465AC1f99E55868Ec.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0x54bae63e59B422Dd7C047E375f051D60C37cb60F.jpg b/public/images/10-year-anniversary/torchbearers/0x54bae63e59B422Dd7C047E375f051D60C37cb60F.jpg new file mode 100644 index 00000000000..78d3a8c497d Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0x54bae63e59B422Dd7C047E375f051D60C37cb60F.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0x648aA14e4424e0825A5cE739C8C68610e143FB79.jpg b/public/images/10-year-anniversary/torchbearers/0x648aA14e4424e0825A5cE739C8C68610e143FB79.jpg new file mode 100644 index 00000000000..579ff40312a Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0x648aA14e4424e0825A5cE739C8C68610e143FB79.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0x7a16fF8270133F063aAb6C9977183D9e72835428.jpg b/public/images/10-year-anniversary/torchbearers/0x7a16fF8270133F063aAb6C9977183D9e72835428.jpg new file mode 100644 index 00000000000..172a905f938 Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0x7a16fF8270133F063aAb6C9977183D9e72835428.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0x88C2C3C9E64a1299e6417C24Fa2ae773c6cEa47c.jpg b/public/images/10-year-anniversary/torchbearers/0x88C2C3C9E64a1299e6417C24Fa2ae773c6cEa47c.jpg new file mode 100644 index 00000000000..baa436665a4 Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0x88C2C3C9E64a1299e6417C24Fa2ae773c6cEa47c.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0xA307A15d113D9763C6fc84768AC34909438bB2EE.jpg b/public/images/10-year-anniversary/torchbearers/0xA307A15d113D9763C6fc84768AC34909438bB2EE.jpg new file mode 100644 index 00000000000..56f93421537 Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0xA307A15d113D9763C6fc84768AC34909438bB2EE.jpg differ diff --git a/public/images/10-year-anniversary/torchbearers/0xcc2047a4108033Cb48727B8C69914F40cC0bBC1B.jpg b/public/images/10-year-anniversary/torchbearers/0xcc2047a4108033Cb48727B8C69914F40cC0bBC1B.jpg new file mode 100644 index 00000000000..554cdfb2cd8 Binary files /dev/null and b/public/images/10-year-anniversary/torchbearers/0xcc2047a4108033Cb48727B8C69914F40cC0bBC1B.jpg differ diff --git a/public/videos/10y-video.mp4 b/public/videos/10y-video.mp4 new file mode 100755 index 00000000000..21e08ebbf50 Binary files /dev/null and b/public/videos/10y-video.mp4 differ diff --git a/src/components/YouTube.tsx b/src/components/YouTube.tsx index 3b5bb23a5cc..58e4e26128b 100644 --- a/src/components/YouTube.tsx +++ b/src/components/YouTube.tsx @@ -3,6 +3,8 @@ import React from "react" import LiteYouTubeEmbed from "react-lite-youtube-embed" +import { cn } from "@/lib/utils/cn" + import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css" /** @@ -19,13 +21,20 @@ type YouTubeProps = { id: string start?: string title?: string -} + className?: string +} & React.ComponentProps -const YouTube = ({ id, start = "0", title = "YouTube" }: YouTubeProps) => { +const YouTube = ({ + id, + start = "0", + title = "YouTube", + className, + ...props +}: YouTubeProps) => { const params = new URLSearchParams() ;+start > 0 && params.set("start", start) return ( -
+
{ title={title} params={params.toString()} noCookie + {...props} />
) diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 5e5c20a09d9..fa2fb3494ab 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -172,7 +172,7 @@ const Avatar = React.forwardRef< sizes="4rem" src={src} alt={name} - quality={90} + quality={100} /> ) : ( @@ -195,7 +195,7 @@ const Avatar = React.forwardRef< sizes="4rem" src={src} alt={name} - quality={90} + quality={100} /> ) : ( diff --git a/src/config/rainbow-kit.ts b/src/config/rainbow-kit.ts index 5ed4ba6c3bd..5d580f113d9 100644 --- a/src/config/rainbow-kit.ts +++ b/src/config/rainbow-kit.ts @@ -1,4 +1,5 @@ -import { mainnet } from "wagmi/chains" +import { http } from "wagmi" +import { hardhat, mainnet, sepolia } from "wagmi/chains" import { getDefaultConfig } from "@rainbow-me/rainbowkit" import { coinbaseWallet, @@ -9,10 +10,62 @@ import { zerionWallet, } from "@rainbow-me/rainbowkit/wallets" +const CHAIN_MAP = { + hardhat, + sepolia, + mainnet, +} as const + +// Determine which chains to use based on env vars +export const getTargetChains = () => { + const chainNames = + process.env.NEXT_PUBLIC_CHAIN_NAMES?.split(",").map((name) => + name.trim() + ) || [] + + if (chainNames.length === 0) { + return [hardhat] + } + + // Map chain names to actual chain objects + const validChains = chainNames + .map((name) => CHAIN_MAP[name as keyof typeof CHAIN_MAP]) + .filter(Boolean) + + // If no valid chains found, fallback to just hardhat + if (validChains.length === 0) { + console.warn( + `No valid chains found for: ${chainNames.join(", ")}. Falling back to hardhat.` + ) + return [hardhat] + } + + return validChains +} + +const getTransports = () => { + const alchemyApiKey = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY + + if (!alchemyApiKey) { + console.warn( + "NEXT_PUBLIC_ALCHEMY_API_KEY not found, falling back to public RPC" + ) + return undefined + } + + return { + [mainnet.id]: http(`https://eth-mainnet.g.alchemy.com/v2/${alchemyApiKey}`), + [sepolia.id]: http(`https://eth-sepolia.g.alchemy.com/v2/${alchemyApiKey}`), + [hardhat.id]: http("http://127.0.0.1:8545"), + } +} + export const rainbowkitConfig = getDefaultConfig({ appName: "ethereum.org", projectId: process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID!, - chains: [mainnet], + // @ts-expect-error - TODO: fix this + chains: getTargetChains(), + transports: getTransports(), wallets: [ { groupName: "New to crypto", diff --git a/src/data/contracts/TenYearsNFT.json b/src/data/contracts/TenYearsNFT.json new file mode 100644 index 00000000000..9d18376af14 --- /dev/null +++ b/src/data/contracts/TenYearsNFT.json @@ -0,0 +1,662 @@ +{ + "abi": [ + { + "inputs": [ + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol", + "type": "string" + }, + { + "internalType": "address", + "name": "initialOwner", + "type": "address" + }, + { + "internalType": "string", + "name": "baseURI", + "type": "string" + }, + { + "internalType": "uint256", + "name": "_startTime", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "_endTime", + "type": "uint256" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "ERC721IncorrectOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ERC721InsufficientApproval", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "approver", + "type": "address" + } + ], + "name": "ERC721InvalidApprover", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "ERC721InvalidOperator", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "ERC721InvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "receiver", + "type": "address" + } + ], + "name": "ERC721InvalidReceiver", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "sender", + "type": "address" + } + ], + "name": "ERC721InvalidSender", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ERC721NonexistentToken", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "OwnableInvalidOwner", + "type": "error" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "account", + "type": "address" + } + ], + "name": "OwnableUnauthorizedAccount", + "type": "error" + }, + { + "inputs": [], + "name": "ReentrancyGuardReentrantCall", + "type": "error" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "approved", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "indexed": false, + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "ApprovalForAll", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "previousOwner", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "TokenMinted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "Transfer", + "type": "event" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "approve", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + } + ], + "name": "balanceOf", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "endTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "getApproved", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "name": "hasMinted", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "address", + "name": "operator", + "type": "address" + } + ], + "name": "isApprovedForAll", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "isMintingActive", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "mint", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "ownerOf", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "renounceOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes" + } + ], + "name": "safeTransferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "operator", + "type": "address" + }, + { + "internalType": "bool", + "name": "approved", + "type": "bool" + } + ], + "name": "setApprovalForAll", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "baseURI", + "type": "string" + } + ], + "name": "setBaseURI", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "startTime", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "interfaceId", + "type": "bytes4" + } + ], + "name": "supportsInterface", + "outputs": [ + { + "internalType": "bool", + "name": "", + "type": "bool" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "tokenURI", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "totalSupply", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "internalType": "address", + "name": "to", + "type": "address" + }, + { + "internalType": "uint256", + "name": "tokenId", + "type": "uint256" + } + ], + "name": "transferFrom", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newOwner", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } + ] +} diff --git a/src/data/contracts/TenYearsNFT.ts b/src/data/contracts/TenYearsNFT.ts new file mode 100644 index 00000000000..a9112e88f61 --- /dev/null +++ b/src/data/contracts/TenYearsNFT.ts @@ -0,0 +1,30 @@ +import { hardhat, mainnet, sepolia } from "wagmi/chains" + +import TenYearsNFT from "./TenYearsNFT.json" + +export const TEN_YEARS_NFT_CONTRACTS = { + [hardhat.id]: { + address: "0x5FbDB2315678afecb367f032d93F642f64180aa3", + blockNumber: 1, + abi: TenYearsNFT.abi, + }, + [sepolia.id]: { + address: "0x388B10E1F9aC2a0a6bd874d00a971875Ae89Ec6E", + blockNumber: 8863414, + abi: TenYearsNFT.abi, + }, + [mainnet.id]: { + address: "0x26d85a13212433fe6a8381969c2b0db390a0b0ae", + blockNumber: 23023215, + abi: TenYearsNFT.abi, + }, +} as const + +export const getTenYearsNFTContract = (chainId: number) => { + return TEN_YEARS_NFT_CONTRACTS[ + chainId as keyof typeof TEN_YEARS_NFT_CONTRACTS + ] +} + +export type TenYearsNFTContract = + (typeof TEN_YEARS_NFT_CONTRACTS)[keyof typeof TEN_YEARS_NFT_CONTRACTS] diff --git a/src/data/torchHolders.json b/src/data/torchHolders.json new file mode 100644 index 00000000000..9e6d03fc525 --- /dev/null +++ b/src/data/torchHolders.json @@ -0,0 +1,62 @@ +[ + { + "address": "0x88C2C3C9E64a1299e6417C24Fa2ae773c6cEa47c", + "name": "Joseph Lubin", + "role": "Co-founder of Ethereum", + "twitter": "https://x.com/ethereumJoseph" + }, + { + "address": "0x11adBC1B3fd5cb5F29B0052b4AfFe725645b5e4C", + "name": "Audrey Tang", + "role": "Cyber Ambassador, 1st Digital Minister of Taiwan (2016-2024)", + "twitter": "https://x.com/audreyt" + }, + { + "address": "0xcc2047a4108033Cb48727B8C69914F40cC0bBC1B", + "name": "Manoj Gorle", + "role": "Co-president 0xblocsoc, Undergrad IIT Delhi", + "twitter": "https://x.com/manojkgorle" + }, + { + "address": "0x5F19021618AF1cEB5De7Ca112B505F51f813aE18", + "name": "Roman and Alexey Defense Fund", + "role": "", + "twitter": "" + }, + { + "address": "0x7a16fF8270133F063aAb6C9977183D9e72835428", + "name": "Michael Egorov", + "role": "Founder of Curve Finance", + "twitter": "https://x.com/newmichwill" + }, + { + "address": "0x0c004944e16e9065Da1c7dB49F9964E2a3ac8892", + "name": "Letícia Pires", + "role": "CEO Pomodoki", + "twitter": "https://x.com/letispires" + }, + { + "address": "0x54bae63e59B422Dd7C047E375f051D60C37cb60F", + "name": "Ayodeje Ebunayo", + "role": "Founder of Web3Bridge", + "twitter": "https://x.com/Ebunayo08" + }, + { + "address": "0xA307A15d113D9763C6fc84768AC34909438bB2EE", + "name": "Alex Bornyakov", + "role": "The Deputy Minister of Digital Transformation of Ukraine", + "twitter": "https://x.com/abornyakov" + }, + { + "address": "0x648aA14e4424e0825A5cE739C8C68610e143FB79", + "name": "Anthony Sassano", + "role": "Founder of The Daily Gwei", + "twitter": "https://x.com/sassal0x" + }, + { + "address": "0x36ACC9E5248f33B030d3eA3465AC1f99E55868Ec", + "name": "Candela Fassano", + "role": "Builder SEED Latam", + "twitter": "https://x.com/candufaz" + } +] diff --git a/src/hooks/useGasPrice.ts b/src/hooks/useGasPrice.ts new file mode 100644 index 00000000000..1616dba1a71 --- /dev/null +++ b/src/hooks/useGasPrice.ts @@ -0,0 +1,100 @@ +import { useEffect, useState } from "react" +import { usePublicClient } from "wagmi" + +interface GasPriceData { + standard: number // in gwei + fast: number // in gwei + instant: number // in gwei + timestamp: number +} + +interface GasPriceState { + data: GasPriceData | null + loading: boolean + error: Error | null +} + +// Gas price thresholds in gwei +export const GAS_THRESHOLDS = { + LOW: 20, + MODERATE: 40, + HIGH: 80, + VERY_HIGH: 150, +} as const + +export type GasPriceLevel = "low" | "moderate" | "high" | "very_high" + +export const getGasPriceLevel = (gasPrice: number): GasPriceLevel => { + if (gasPrice >= GAS_THRESHOLDS.VERY_HIGH) return "very_high" + if (gasPrice >= GAS_THRESHOLDS.HIGH) return "high" + if (gasPrice >= GAS_THRESHOLDS.MODERATE) return "moderate" + return "low" +} + +export const useGasPrice = () => { + const [state, setState] = useState({ + data: null, + loading: true, + error: null, + }) + + const publicClient = usePublicClient() + + useEffect(() => { + const fetchGasPrice = async () => { + if (!publicClient) return + + try { + setState((prev) => ({ ...prev, loading: true, error: null })) + + // Get current gas price from the network + const gasPrice = await publicClient.getGasPrice() + + // Convert from wei to gwei + const gasPriceGwei = Number(gasPrice) / 1e9 + + // For simplicity, we'll use the network gas price as standard + // and calculate fast/instant as multiples + const gasPriceData: GasPriceData = { + standard: Math.round(gasPriceGwei), + fast: Math.round(gasPriceGwei * 1.2), + instant: Math.round(gasPriceGwei * 1.5), + timestamp: Date.now(), + } + + setState({ + data: gasPriceData, + loading: false, + error: null, + }) + } catch (error) { + console.error("Failed to fetch gas price:", error) + setState({ + data: null, + loading: false, + error: + error instanceof Error + ? error + : new Error("Failed to fetch gas price"), + }) + } + } + + fetchGasPrice() + + // Refresh gas price every 30 seconds + const interval = setInterval(fetchGasPrice, 30000) + + return () => clearInterval(interval) + }, [publicClient]) + + const gasLevel = state.data ? getGasPriceLevel(state.data.standard) : null + const shouldWarn = gasLevel === "high" || gasLevel === "very_high" + + return { + ...state, + gasLevel, + shouldWarn, + refresh: () => setState((prev) => ({ ...prev, loading: true })), + } +} diff --git a/src/hooks/useNetworkContract.ts b/src/hooks/useNetworkContract.ts new file mode 100644 index 00000000000..401596dbcd8 --- /dev/null +++ b/src/hooks/useNetworkContract.ts @@ -0,0 +1,45 @@ +import { useAccount, useChainId } from "wagmi" +import { hardhat, mainnet, sepolia } from "wagmi/chains" + +import { getTenYearsNFTContract } from "@/data/contracts/TenYearsNFT" + +import { getTargetChains } from "@/config/rainbow-kit" + +export const useNetworkContract = () => { + const { chainId: accountChainId } = useAccount() + const chainId = useChainId() + + const getContractData = () => { + const contractData = getTenYearsNFTContract(chainId) + + if (!contractData) { + throw new Error(`Contract not deployed on chain ${chainId}`) + } + + return contractData + } + + const isSupportedNetwork = () => { + return getTargetChains().some((chain) => chain.id === accountChainId) + } + + const getNetworkName = () => { + switch (chainId) { + case hardhat.id: + return "Hardhat (Local)" + case sepolia.id: + return "Sepolia" + case mainnet.id: + return "Mainnet" + default: + return "Unsupported Network" + } + } + + return { + chainId, + contractData: getContractData(), + isSupportedNetwork: isSupportedNetwork(), + networkName: getNetworkName(), + } +} diff --git a/src/intl/en/common.json b/src/intl/en/common.json index 06ff2f39dad..595efcfd0cd 100644 --- a/src/intl/en/common.json +++ b/src/intl/en/common.json @@ -431,6 +431,7 @@ "statelessness": "Statelessness", "style-guide": "Style guide", "support": "Support", + "terms-and-conditions": "Terms & Conditions", "terms-of-use": "Terms of use", "translation-banner-body-new": "You’re viewing this page in English because we haven’t translated it yet. Help us translate this content.", "translation-banner-body-update": "There’s a new version of this page but it’s only in English right now. Help us translate the latest version.", diff --git a/src/lib/ab-testing/server.ts b/src/lib/ab-testing/server.ts index 42df90fb1d6..8dd9032787f 100644 --- a/src/lib/ab-testing/server.ts +++ b/src/lib/ab-testing/server.ts @@ -24,12 +24,26 @@ export const getABTestAssignment = async ( if (!testConfig || !testConfig.enabled) return null - // Create deterministic assignment using IP + User-Agent fingerprint + // Create deterministic assignment using enhanced fingerprint const headers = await import("next/headers").then((m) => m.headers()) - const userAgent = headers.get("user-agent") || "" + + // Get IP and user agent (primary identifier) const forwardedFor = headers.get("x-forwarded-for") || headers.get("x-real-ip") || "unknown" - const fingerprint = `${forwardedFor}-${userAgent}` + const userAgent = headers.get("user-agent") || "" + + // Add privacy-preserving entropy sources + const acceptLanguage = headers.get("accept-language") || "" + const acceptEncoding = headers.get("accept-encoding") || "" + + // Create enhanced fingerprint with more entropy + const fingerprint = [ + forwardedFor, + userAgent, + acceptLanguage, + acceptEncoding, + testKey, // Include test key to ensure different tests get different distributions + ].join("|") const variantIndex = assignVariantIndexDeterministic(testConfig, fingerprint) const variant = testConfig.variants[variantIndex] @@ -56,23 +70,22 @@ const assignVariantIndexDeterministic = ( // Handle case where total weight is 0 if (totalWeight === 0) return 0 - // Use a better hash function for more uniform distribution - // This is a simple implementation of djb2 hash algorithm - let hash = 5381 + // Hash function to evenly distribute fingerprints amongst assignments + // Implementation of FNV-1a hash algorithm + let hash = 2166136261 // FNV offset basis for (let i = 0; i < fingerprint.length; i++) { - hash = (hash << 5) + hash + fingerprint.charCodeAt(i) + hash ^= fingerprint.charCodeAt(i) // XOR + hash = (hash * 16777619) >>> 0 // FNV prime, ensure 32-bit unsigned } - // Ensure positive value and create uniform distribution - const normalized = Math.abs(hash) / 0x7fffffff // Max 32-bit signed int + // Convert to uniform distribution [0, 1) + const normalized = hash / 0x100000000 // 2^32 for full 32-bit range const weighted = normalized * totalWeight let cumulativeWeight = 0 for (let i = 0; i < config.variants.length; i++) { cumulativeWeight += config.variants[i].weight - if (weighted <= cumulativeWeight) { - return i - } + if (weighted <= cumulativeWeight) return i } return 0 diff --git a/src/lib/api/fetchTorchHolders.ts b/src/lib/api/fetchTorchHolders.ts deleted file mode 100644 index 92e39945868..00000000000 --- a/src/lib/api/fetchTorchHolders.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { resolveEnsName, type TorchHolder } from "@/lib/torch" - -export async function fetchTorchHolders(): Promise { - const googleApiKey = process.env.GOOGLE_API_KEY - const sheetId = process.env.GOOGLE_SHEET_ID_TORCH_HOLDERS - - if (!googleApiKey) { - console.warn("Google API key not set") - return [] - } - - if (!sheetId) { - console.warn("Google Sheet ID for torch holders not set") - return [] - } - - try { - const url = `https://sheets.googleapis.com/v4/spreadsheets/${sheetId}/values/Website%20Info!A:E?majorDimension=ROWS&key=${googleApiKey}` - const response = await fetch(url) - - if (!response.ok) { - const errorText = await response.text() - console.error("Google Sheets API Error:", { - status: response.status, - statusText: response.statusText, - error: errorText, - }) - throw new Error( - `Google Sheets API responded with ${response.status}: ${response.statusText}` - ) - } - - const data = await response.json() - // data.values[0] is the header row - const rows = data.values.slice(1) || [] - - // Map rows to TorchHolder objects with ENS resolution - const holders: TorchHolder[] = [] - - for (const row of rows) { - if (!row[0]) continue // must have address or ENS name - - const addressOrEns = row[0].trim() - const resolvedAddress = await resolveEnsName(addressOrEns) - - if (resolvedAddress) { - holders.push({ - address: resolvedAddress, - name: row[1] || "", - role: row[2] || "", - twitter: row[3] || "", - }) - } else { - console.warn(`Could not resolve address or ENS name: ${addressOrEns}`) - } - } - - return holders - } catch (error) { - console.error("Error fetching torch holders from Google Sheets:", error) - return [] - } -} diff --git a/src/lib/torch/config.ts b/src/lib/torch/config.ts index 5ea93327418..3a8eb0c14e0 100644 --- a/src/lib/torch/config.ts +++ b/src/lib/torch/config.ts @@ -5,10 +5,10 @@ export const config = createConfig({ chains: [mainnet], transports: { [mainnet.id]: http( - `https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + `https://eth-mainnet.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_API_KEY}` ), // [sepolia.id]: http( - // `https://eth-sepolia.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}` + // `https://eth-sepolia.g.alchemy.com/v2/${process.env.NEXT_PUBLIC_ALCHEMY_API_KEY}` // ), // [hardhat.id]: http("http://127.0.0.1:8545"), }, diff --git a/src/lib/torch/index.ts b/src/lib/torch/index.ts index 98e2d8be3bf..9390bd7c307 100644 --- a/src/lib/torch/index.ts +++ b/src/lib/torch/index.ts @@ -125,10 +125,8 @@ export const getAvatarImage = (holder: TorchHolderMetadata | null) => { // If there's a Twitter handle, use Twitter profile image if (holder.twitter && holder.twitter.trim() !== "") { - const twitterHandle = extractTwitterHandle(holder.twitter) - if (twitterHandle) { - return `https://unavatar.io/x/${twitterHandle}` - } + const address = holder.address + return `/images/10-year-anniversary/torchbearers/${address}.jpg` } // Otherwise, fall back to blockie @@ -154,7 +152,7 @@ export const extractTwitterHandle = (twitterUrl: string): string | null => { } export const formatAddress = (address: Address) => { - return `${address.slice(0, 6)}...${address.slice(-4)}` + return `${address.slice(0, 7)}...${address.slice(-5)}` } export const formatDate = (timestamp: number) => { @@ -198,3 +196,20 @@ export async function resolveEnsName( return null } } + +export const getErrorMessage = (error: Error) => { + if (error.message.includes("insufficient funds")) { + return "Insufficient funds" + } + if (error.message.includes("not enough ETH")) { + return "Not enough ETH" + } + if (error.message.includes("EnforcedPause")) { + return "Contract is paused" + } + if (error.message.includes("already minted")) { + return "You have already minted an NFT" + } + + return "An error occurred during minting" +}