diff --git a/package-lock.json b/package-lock.json
index 50ef49b..0411ca6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "plotlink",
- "version": "1.35.0",
+ "version": "1.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plotlink",
- "version": "1.35.0",
+ "version": "1.36.0",
"workspaces": [
"packages/*"
],
diff --git a/package.json b/package.json
index 05b44a3..fe9fa37 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "plotlink",
- "version": "1.35.0",
+ "version": "1.36.0",
"private": true,
"workspaces": [
"packages/*"
diff --git a/src/app/airdrop/AirdropStateMachine.tsx b/src/app/airdrop/AirdropStateMachine.tsx
index b75b3f3..d93702b 100644
--- a/src/app/airdrop/AirdropStateMachine.tsx
+++ b/src/app/airdrop/AirdropStateMachine.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useCallback } from "react";
import { useAccount } from "wagmi";
import { CampaignHero } from "../../components/airdrop/CampaignHero";
import { ActivationFlow } from "../../components/airdrop/ActivationFlow";
@@ -39,6 +39,10 @@ export function AirdropStateMachine() {
const { address, isConnected } = useAccount();
const [fetchResult, setFetchResult] = useState<{ activatedAt: string | null; done: boolean }>({ activatedAt: null, done: !needsFetch });
+ const onActivated = useCallback(() => {
+ setFetchResult({ activatedAt: new Date().toISOString(), done: true });
+ }, []);
+
useEffect(() => {
if (!needsFetch || !isConnected || !address) return;
@@ -107,7 +111,7 @@ export function AirdropStateMachine() {
Connect your wallet to get started.
) : (
-
+
)}
>
diff --git a/src/components/airdrop/ActivationFlow.tsx b/src/components/airdrop/ActivationFlow.tsx
index c6a5105..73cbb8d 100644
--- a/src/components/airdrop/ActivationFlow.tsx
+++ b/src/components/airdrop/ActivationFlow.tsx
@@ -1,10 +1,405 @@
"use client";
-export function ActivationFlow() {
+import { useState, useEffect, useCallback } from "react";
+import { useAccount, useSignMessage } from "wagmi";
+import { SiweMessage } from "siwe";
+import { REFERRAL_STORAGE_KEY } from "../../hooks/useReferralCapture";
+
+type StepState = "idle" | "active" | "done";
+
+interface ActivationStatus {
+ x_handle_confirmed_at: string | null;
+ x_follow_at: string | null;
+ fc_verified_at: string | null;
+ activated_at: string | null;
+}
+
+function StepIndicator({ state, label }: { state: StepState; label: string }) {
return (
-
-
Activate Your Wallet
-
Connect your X account and follow PlotLink to activate.
+
+ {state === "done" && ✓}
+ {state === "active" && ●}
+ {state === "idle" && ○}
+ {label}
+
+ );
+}
+
+interface ActivationFlowProps {
+ onActivated?: () => void;
+}
+
+export function ActivationFlow({ onActivated }: ActivationFlowProps) {
+ const { address, chainId } = useAccount();
+ const { signMessageAsync } = useSignMessage();
+
+ const [siweMessage, setSiweMessage] = useState
(null);
+ const [signature, setSignature] = useState(null);
+ const [step, setStep] = useState<1 | 2 | 3>(1);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const [xUsername, setXUsername] = useState("");
+ const [xPreview, setXPreview] = useState<{ display_name: string; avatar_url: string; follower_count: number } | null>(null);
+ const [xConfirmed, setXConfirmed] = useState(false);
+ const [xFollowed, setXFollowed] = useState(false);
+ const [fcVerified, setFcVerified] = useState(false);
+ const [fcUsername, setFcUsername] = useState("");
+
+ useEffect(() => {
+ if (!address) return;
+ fetch(`/api/airdrop/activation-status?address=${address.toLowerCase()}`)
+ .then(r => r.json())
+ .then((data: ActivationStatus) => {
+ if (data.x_handle_confirmed_at) {
+ setXConfirmed(true);
+ if (data.x_follow_at) setXFollowed(true);
+ if (data.fc_verified_at) setFcVerified(true);
+ if (data.activated_at) {
+ setStep(3);
+ setXFollowed(true);
+ } else if (data.x_follow_at) {
+ setStep(3);
+ } else {
+ setStep(3);
+ }
+ }
+ })
+ .catch(() => {});
+ }, [address]);
+
+ const buildSiweMessage = useCallback(() => {
+ if (!address || !chainId) return null;
+ const msg = new SiweMessage({
+ domain: "plotlink.xyz",
+ address,
+ statement: "PlotLink Buy-Back Sprint activation",
+ uri: "https://plotlink.xyz/airdrop",
+ version: "1",
+ chainId,
+ nonce: Math.random().toString(36).slice(2, 10),
+ issuedAt: new Date().toISOString(),
+ });
+ return msg.prepareMessage();
+ }, [address, chainId]);
+
+ const handleSign = async () => {
+ setError(null);
+ setLoading(true);
+ try {
+ const msg = buildSiweMessage();
+ if (!msg) throw new Error("Wallet not ready");
+ const sig = await signMessageAsync({ message: msg });
+ setSiweMessage(msg);
+ setSignature(sig);
+
+ const refCode = localStorage.getItem(REFERRAL_STORAGE_KEY);
+ if (refCode) {
+ try {
+ const res = await fetch("/api/airdrop/register-referral", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: msg, signature: sig, referralCode: refCode }),
+ });
+ if (res.ok || res.status === 400 || res.status === 404 || res.status === 409) {
+ localStorage.removeItem(REFERRAL_STORAGE_KEY);
+ }
+ } catch {
+ // transient error — keep localStorage for retry
+ }
+ }
+
+ if (xConfirmed) {
+ setStep(3);
+ } else {
+ setStep(2);
+ }
+ } catch (err) {
+ if (err instanceof Error && err.message.includes("User rejected")) {
+ setError("Signature rejected — please try again.");
+ } else {
+ setError("Failed to sign. Please try again.");
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleVerifyX = async () => {
+ if (!siweMessage || !signature || !xUsername.trim()) return;
+ setError(null);
+ setLoading(true);
+ try {
+ const res = await fetch("/api/airdrop/verify-x-handle", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: siweMessage, signature, username: xUsername.trim() }),
+ });
+ if (res.status === 401) {
+ setError("Signature expired. Please re-sign.");
+ setSiweMessage(null);
+ setSignature(null);
+ setStep(1);
+ return;
+ }
+ if (res.status === 404) {
+ setError("X user not found. Check the handle and try again.");
+ return;
+ }
+ if (!res.ok) {
+ setError("Couldn't verify right now. Try again shortly.");
+ return;
+ }
+ const data = await res.json();
+ setXPreview(data);
+ } catch {
+ setError("Couldn't verify right now. Try again shortly.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleConfirmX = async () => {
+ if (!siweMessage || !signature || !xUsername.trim()) return;
+ setError(null);
+ setLoading(true);
+ try {
+ const res = await fetch("/api/airdrop/confirm-x-handle", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: siweMessage, signature, username: xUsername.trim() }),
+ });
+ if (res.status === 401) {
+ setError("Signature expired. Please re-sign.");
+ setSiweMessage(null);
+ setSignature(null);
+ setStep(1);
+ return;
+ }
+ if (res.status === 409) {
+ setError("This X account is already linked to another wallet.");
+ return;
+ }
+ if (!res.ok) {
+ setError("Couldn't confirm right now. Try again shortly.");
+ return;
+ }
+ setXConfirmed(true);
+ setXPreview(null);
+ setStep(3);
+ } catch {
+ setError("Couldn't confirm right now. Try again shortly.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleXFollow = async () => {
+ window.open("https://x.com/intent/follow?screen_name=plotlinkxyz", "_blank");
+ if (!siweMessage || !signature) return;
+ setError(null);
+ setLoading(true);
+ try {
+ const res = await fetch("/api/airdrop/x-follow-click", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: siweMessage, signature }),
+ });
+ if (res.status === 401) {
+ setError("Signature expired. Please re-sign.");
+ setSiweMessage(null);
+ setSignature(null);
+ setStep(1);
+ return;
+ }
+ if (res.ok) {
+ setXFollowed(true);
+ const data = await res.json();
+ if (data.activated) onActivated?.();
+ }
+ } catch {
+ // non-blocking
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleVerifyFc = async () => {
+ if (!siweMessage || !signature || !fcUsername.trim()) return;
+ setError(null);
+ setLoading(true);
+ try {
+ const res = await fetch("/api/airdrop/verify-fc", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: siweMessage, signature, username: fcUsername.trim() }),
+ });
+ if (res.status === 401) {
+ setError("Signature expired. Please re-sign.");
+ setSiweMessage(null);
+ setSignature(null);
+ setStep(1);
+ return;
+ }
+ if (res.status === 404) {
+ setError("Couldn't find that FC user.");
+ return;
+ }
+ if (res.status === 422) {
+ setError("Looks like you don't follow @plotlink yet — follow and click verify again.");
+ return;
+ }
+ if (res.status === 409) {
+ setError("This Farcaster account is already linked.");
+ return;
+ }
+ if (res.status === 502) {
+ setError("Farcaster verification unavailable right now. You can skip this step.");
+ return;
+ }
+ if (res.ok) {
+ setFcVerified(true);
+ }
+ } catch {
+ setError("Verification failed. You can skip this step.");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const siweStep: StepState = (siweMessage && signature) ? "done" : step === 1 ? "active" : "idle";
+ const xStep: StepState = xConfirmed ? "done" : step === 2 ? "active" : "idle";
+ const missionStep: StepState = xFollowed ? "done" : step === 3 ? "active" : "idle";
+
+ return (
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {step === 1 && (
+
+
Sign a message to verify wallet ownership.
+
+
+ )}
+
+ {step === 2 && (
+
+
Enter your X (Twitter) handle to verify your account.
+
+ { setXUsername(e.target.value); setError(null); }}
+ placeholder="@handle"
+ className="bg-background border-border text-foreground flex-1 rounded border px-3 py-2 text-xs"
+ />
+
+
+
+ {xPreview && (
+
+
+ {xPreview.avatar_url && (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ )}
+
+
{xPreview.display_name}
+
{xPreview.follower_count.toLocaleString()} followers
+
+
+
+
+ )}
+
+ )}
+
+ {step === 3 && (
+
+
+
+ Follow @plotlinkxyz on X
+ {xFollowed ? (
+ ✓ Done
+ ) : (
+
+ )}
+
+
+
+
+ Follow @plotlink on Farcaster (optional)
+ {fcVerified && ✓ Verified}
+
+ {!fcVerified && (
+
+ { setFcUsername(e.target.value); setError(null); }}
+ placeholder="FC username"
+ className="bg-background border-border text-foreground flex-1 rounded border px-3 py-1.5 text-xs"
+ />
+
+
+ )}
+
+
+
+ {!siweMessage && (
+
+
Session expired. Re-sign to continue.
+
+
+ )}
+
+ )}
);
}