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 (
+
-
-
);
}
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..3e98f963
--- /dev/null
+++ b/src/components/airdrop/UserPoints.tsx
@@ -0,0 +1,336 @@
+"use client";
+
+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";
+
+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);
+ const { profile: farcasterProfile } = useConnectedIdentity();
+
+ // 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,
+ hasFarcaster,
+}: {
+ referral: PointsData["referral"];
+ address: string;
+ hasFarcaster: boolean;
+}) {
+ 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}
+
+
+
+
+
+
+ ) : hasFarcaster ? (
+
+
+
+ ) : null}
+
+ {/* Referred users count */}
+
+ Referred users:
+ {referral.referredUsersCount}
+
+
+ {error &&
{error}
}
+
+ );
+}