From ff1c16137c3669c3da45bc543de198d2ff862951 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 14:46:04 +0900 Subject: [PATCH 1/2] [#887] Add user points panel, streak card, and referral UI Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/airdrop/page.tsx | 8 +- src/components/airdrop/StreakCard.tsx | 96 ++++++++ src/components/airdrop/UserPoints.tsx | 332 ++++++++++++++++++++++++++ 3 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 src/components/airdrop/StreakCard.tsx create mode 100644 src/components/airdrop/UserPoints.tsx diff --git a/src/app/airdrop/page.tsx b/src/app/airdrop/page.tsx index f145e653..54649432 100644 --- a/src/app/airdrop/page.tsx +++ b/src/app/airdrop/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { CampaignHero } from "../../components/airdrop/CampaignHero"; +import { UserPoints } from "../../components/airdrop/UserPoints"; import { MilestoneTrack } from "../../components/airdrop/MilestoneTrack"; -import { ReferralInput } from "../../components/ReferralInput"; export const metadata: Metadata = { title: "PLOT 10x Airdrop | PlotLink", @@ -12,12 +12,8 @@ export default function AirdropPage() { return (
+ - -
-

Referral

- -
); } diff --git a/src/components/airdrop/StreakCard.tsx b/src/components/airdrop/StreakCard.tsx new file mode 100644 index 00000000..2a6031ec --- /dev/null +++ b/src/components/airdrop/StreakCard.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useState } from "react"; +import { useAccount, useSignMessage } from "wagmi"; +import { useQueryClient } from "@tanstack/react-query"; + +interface StreakData { + currentStreak: number; + boostPercent: number; + nextTier: { days: number; boost: number } | null; + checkedInToday: boolean; +} + +export function StreakCard({ streak, address }: { streak: StreakData; address: string }) { + const { isConnected } = useAccount(); + const { signMessageAsync } = useSignMessage(); + const queryClient = useQueryClient(); + const [checking, setChecking] = useState(false); + const [error, setError] = useState(""); + + const handleCheckIn = async () => { + if (!isConnected || !address) return; + setError(""); + setChecking(true); + + try { + const message = `${address}\n\nStreak check-in\nTimestamp: ${new Date().toISOString()}`; + const signature = await signMessageAsync({ message }); + + const res = await fetch("/api/airdrop/checkin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message, signature }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error ?? "Check-in failed"); + return; + } + + queryClient.invalidateQueries({ queryKey: ["airdrop-points", address] }); + } catch { + setError("Signature rejected or failed"); + } finally { + setChecking(false); + } + }; + + const progressToNext = streak.nextTier + ? Math.min(100, (streak.currentStreak / streak.nextTier.days) * 100) + : 100; + + return ( +
+
+
+ + Streak: {streak.currentStreak} days + + {streak.boostPercent > 0 && ( + +{streak.boostPercent}% boost + )} +
+ +
+ + {streak.nextTier && ( + <> +
+
+
+
+ Next tier: {streak.nextTier.days} days (+{streak.nextTier.boost * 100}%) + · {streak.currentStreak}/{streak.nextTier.days} +
+ + )} + {!streak.nextTier && ( +
Max streak tier reached
+ )} + + {error &&
{error}
} +
+ ); +} diff --git a/src/components/airdrop/UserPoints.tsx b/src/components/airdrop/UserPoints.tsx new file mode 100644 index 00000000..76021ae8 --- /dev/null +++ b/src/components/airdrop/UserPoints.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { useState } from "react"; +import { useAccount, useSignMessage } from "wagmi"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { StreakCard } from "./StreakCard"; +import { formatUsdValue } from "../../../lib/usd-price"; +import { REFERRAL_STORAGE_KEY } from "../../hooks/useReferralCapture"; + +interface PointsData { + address: string; + totalPoints: number; + sharePercent: number; + breakdown: { buy: number; referral: number; write: number; rate: number }; + streak: { + currentStreak: number; + boostPercent: number; + nextTier: { days: number; boost: number } | null; + checkedInToday: boolean; + }; + referral: { + code: string | null; + isFarcasterUsername: boolean; + referredBy: string | null; + referredUsersCount: number; + }; + estimatedAirdrop: { bronze: number; silver: number; gold: number }; +} + +interface StatusData { + latestPriceUsd: number | null; +} + +const ACTIONS: { key: keyof PointsData["breakdown"]; label: string }[] = [ + { key: "buy", label: "Buying" }, + { key: "referral", label: "Referrals" }, + { key: "write", label: "Writing" }, + { key: "rate", label: "Rating" }, +]; + +function useAirdropPoints(address: string | undefined) { + return useQuery({ + queryKey: ["airdrop-points", address], + queryFn: async () => { + const res = await fetch(`/api/airdrop/points?address=${address!.toLowerCase()}`); + if (!res.ok) throw new Error("Failed to fetch points"); + return res.json(); + }, + enabled: !!address, + staleTime: 60_000, + refetchInterval: 60_000, + }); +} + +export function UserPoints() { + const { address, isConnected } = useAccount(); + + if (!isConnected || !address) { + return ( +
+

Connect your wallet to view your points.

+
+ ); + } + + return ; +} + +function UserPointsInner({ address }: { address: string }) { + const { data, isLoading } = useAirdropPoints(address); + + // Fetch latest price for USD estimates + const { data: statusData } = useQuery({ + queryKey: ["airdrop-status"], + queryFn: async () => { + const res = await fetch("/api/airdrop/status"); + if (!res.ok) throw new Error("Failed to fetch status"); + return res.json(); + }, + staleTime: 60_000, + }); + + if (isLoading || !data) { + return ( +
+
Loading your points...
+
+ ); + } + + const price = statusData?.latestPriceUsd ?? null; + + return ( +
+ {/* Points summary */} +
+
+
+ Your PL Points +
{data.totalPoints.toLocaleString()}
+
+
+
Your share
+
{data.sharePercent.toFixed(2)}%
+
+
+ + {/* Estimated airdrop */} +
+ {(["bronze", "silver", "gold"] as const).map((tier) => { + const amount = data.estimatedAirdrop[tier]; + const usdVal = price && amount > 0 ? formatUsdValue(amount * price) : null; + return ( +
+ Est. if {tier.charAt(0).toUpperCase() + tier.slice(1)}:{" "} + + {amount.toLocaleString()} PLOT + + {usdVal && ({usdVal})} +
+ ); + })} +
+
+ + {/* Streak card */} + + + {/* Point breakdown */} +
+
Breakdown
+
+ {ACTIONS.map(({ key, label }) => { + const pts = data.breakdown[key]; + const pct = data.totalPoints > 0 ? Math.round((pts / data.totalPoints) * 100) : 0; + return ( +
+ {label} + + {pts.toLocaleString()} PL + ({pct}%) + {data.streak.boostPercent > 0 && pts > 0 && ( + +{data.streak.boostPercent}% + )} + +
+ ); + })} +
+
+ + {/* Referral section */} + +
+ ); +} + +function ReferralSection({ + referral, + address, +}: { + referral: PointsData["referral"]; + address: string; +}) { + const { signMessageAsync } = useSignMessage(); + const queryClient = useQueryClient(); + const [referrerCode, setReferrerCode] = useState(() => { + if (typeof window !== "undefined") { + return localStorage.getItem(REFERRAL_STORAGE_KEY) ?? ""; + } + return ""; + }); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [copied, setCopied] = useState(false); + + const origin = typeof window !== "undefined" ? window.location.origin : ""; + const referralLink = referral.code ? `${origin}/airdrop?ref=${referral.code}` : null; + + const handleCopy = async () => { + if (!referralLink) return; + await navigator.clipboard.writeText(referralLink); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const handleShare = () => { + if (!referralLink) return; + const text = `Earn PL points in the PLOT 10x Airdrop! ${referralLink}`; + window.open( + `https://x.com/intent/tweet?text=${encodeURIComponent(text)}`, + "_blank", + ); + }; + + const handleSetReferrer = async () => { + if (!referrerCode.trim()) return; + setError(""); + setSubmitting(true); + try { + const message = `${address}\n\nRegister referral code: ${referrerCode.trim()}\nTimestamp: ${new Date().toISOString()}`; + const signature = await signMessageAsync({ message }); + + const res = await fetch("/api/airdrop/register-referral", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message, signature, referralCode: referrerCode.trim() }), + }); + + const data = await res.json(); + if (!res.ok) { + setError(data.error ?? "Registration failed"); + return; + } + + localStorage.removeItem(REFERRAL_STORAGE_KEY); + queryClient.invalidateQueries({ queryKey: ["airdrop-points", address] }); + } catch { + setError("Signature rejected or failed"); + } finally { + setSubmitting(false); + } + }; + + // Generate referral code via Farcaster username + const handleUseFarcaster = async () => { + setError(""); + setSubmitting(true); + try { + const message = `${address}\n\nGenerate referral code with Farcaster username\nTimestamp: ${new Date().toISOString()}`; + const signature = await signMessageAsync({ message }); + + const res = await fetch("/api/airdrop/referral-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message, signature, useFarcaster: true }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error ?? "Failed to generate code"); + return; + } + + queryClient.invalidateQueries({ queryKey: ["airdrop-points", address] }); + } catch { + setError("Signature rejected or failed"); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
Referral
+ + {/* Referred by */} + {referral.referredBy ? ( +
+ Referred by: + {referral.referredBy} +
+ ) : ( +
+
Who referred you?
+
+ setReferrerCode(e.target.value)} + placeholder="Enter referral code" + className="bg-surface border-border text-foreground placeholder:text-muted flex-1 rounded border px-2 py-1 text-xs font-mono focus:border-accent focus:outline-none" + /> + +
+
+ )} + + {/* Your referral link */} + {referralLink ? ( +
+
Your referral link
+
+ {referralLink} +
+
+ + +
+
+ ) : ( +
+ +
+ )} + + {/* Referred users count */} +
+ Referred users: + {referral.referredUsersCount} +
+ + {error &&
{error}
} +
+ ); +} From 5b4e397f3805f0bf80b34085b350e5c8f4f7794e Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Tue, 21 Apr 2026 14:48:40 +0900 Subject: [PATCH 2/2] [#887] Gate Farcaster referral code button on linked account Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/airdrop/UserPoints.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/airdrop/UserPoints.tsx b/src/components/airdrop/UserPoints.tsx index 76021ae8..3e98f963 100644 --- a/src/components/airdrop/UserPoints.tsx +++ b/src/components/airdrop/UserPoints.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useAccount, useSignMessage } from "wagmi"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { StreakCard } from "./StreakCard"; +import { useConnectedIdentity } from "../../hooks/useConnectedIdentity"; import { formatUsdValue } from "../../../lib/usd-price"; import { REFERRAL_STORAGE_KEY } from "../../hooks/useReferralCapture"; @@ -68,6 +69,7 @@ export function UserPoints() { function UserPointsInner({ address }: { address: string }) { const { data, isLoading } = useAirdropPoints(address); + const { profile: farcasterProfile } = useConnectedIdentity(); // Fetch latest price for USD estimates const { data: statusData } = useQuery({ @@ -150,7 +152,7 @@ function UserPointsInner({ address }: { address: string }) {
{/* Referral section */} - + ); } @@ -158,9 +160,11 @@ function UserPointsInner({ address }: { address: string }) { function ReferralSection({ referral, address, + hasFarcaster, }: { referral: PointsData["referral"]; address: string; + hasFarcaster: boolean; }) { const { signMessageAsync } = useSignMessage(); const queryClient = useQueryClient(); @@ -307,7 +311,7 @@ function ReferralSection({ - ) : ( + ) : hasFarcaster ? (
- )} + ) : null} {/* Referred users count */}