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.

+ +
+ )} +
+ )}
); }