From d579aab2ddac1278ce7f8a469838de3604901bcd Mon Sep 17 00:00:00 2001 From: Nikita Date: Sat, 28 Feb 2026 01:13:00 +0400 Subject: [PATCH 1/5] feat: update invite and earn page --- src/App.tsx | 8 + src/api/backend.ts | 74 ++++ src/atoms/invitationAtoms.ts | 1 + src/components/modals/ProfileEditModal.tsx | 7 + .../Invitation/CollectRewardsCard.tsx | 8 +- .../components/Invitation/InvitationList.tsx | 2 +- .../Invitation/InviteAndEarnCard.tsx | 8 +- src/features/trending/hooks/useInvitations.ts | 11 +- src/hooks/__tests__/useXInviteFlow.test.ts | 100 +++++ src/hooks/useXInviteFlow.ts | 199 ++++++++++ src/utils/xInvite.ts | 56 +++ src/views/ProfileXCallback.tsx | 51 ++- src/views/Trendminer/Invite.tsx | 374 +++++++++++++----- src/views/__tests__/ProfileXCallback.test.tsx | 39 ++ 14 files changed, 831 insertions(+), 107 deletions(-) create mode 100644 src/hooks/__tests__/useXInviteFlow.test.ts create mode 100644 src/hooks/useXInviteFlow.ts create mode 100644 src/utils/xInvite.ts diff --git a/src/App.tsx b/src/App.tsx index f57f2453b..9176261ce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import './styles/mobile-optimizations.scss'; import { AppHeader } from './components/layout/app-header'; import { useSuperheroChainNames } from './hooks/useChainName'; import FeedbackButton from './components/FeedbackButton'; +import { parseXInviteCodeFromWindow, storeXInviteCode } from './utils/xInvite'; const CookiesDialog = React.lazy( () => import('./components/modals/CookiesDialog'), @@ -84,6 +85,13 @@ const App = () => { }; }, [activeAccount]); + useEffect(() => { + const parsedCode = parseXInviteCodeFromWindow(); + if (parsedCode) { + storeXInviteCode(parsedCode); + } + }, []); + return (
diff --git a/src/api/backend.ts b/src/api/backend.ts index 5dedb3378..81eec50f5 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -68,6 +68,50 @@ export type XAttestationResponse = { signature_base64: string; }; +export type XInviteChallengePurpose = 'create' | 'bind'; + +export type XInviteChallengeRequest = { + address: string; + purpose: XInviteChallengePurpose; + code?: string; +}; + +export type XInviteChallengeResponse = { + nonce: string; + expires_at: number | string; + message: string; +}; + +export type XInviteCreateRequest = { + inviter_address: string; + challenge_nonce: string; + challenge_expires_at: string; + signature_hex: string; +}; + +export type XInviteCreateResponse = { + code: string; + invite_link: string; +}; + +export type XInviteBindRequest = { + invitee_address: string; + challenge_nonce: string; + challenge_expires_at: string; + signature_hex: string; +}; + +export type XInviteMilestoneRewardStatus = 'not_started' | 'pending' | 'paid' | 'failed'; + +export type XInviteProgressResponse = { + inviter_address: string; + verified_friends_count: number; + goal: number; + remaining_to_goal: number; + milestone_reward_status: XInviteMilestoneRewardStatus; + milestone_reward_tx_hash?: string | null; +}; + // Superhero API client export const SuperheroApi = { async fetchJson(path: string, init?: RequestInit) { @@ -396,6 +440,36 @@ export const SuperheroApi = { }), }) as Promise; }, + createXInviteChallenge(payload: XInviteChallengeRequest) { + return this.fetchJson('/api/profile/x-invites/challenge', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) as Promise; + }, + createXInvite(payload: XInviteCreateRequest) { + return this.fetchJson('/api/profile/x-invites', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) as Promise; + }, + bindXInvite(code: string, payload: XInviteBindRequest) { + return this.fetchJson(`/api/profile/x-invites/${encodeURIComponent(code)}/bind`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + }, + getXInviteProgress(address: string) { + return this.fetchJson(`/api/profile/${encodeURIComponent(address)}/x-invite-progress`) as Promise; + }, /** @deprecated Legacy profile update flow; use on-chain writes instead. */ issueProfileChallenge(address: string, payload: ProfileEditablePayload) { return this.fetchJson(`/api/profile/${encodeURIComponent(address)}/challenge`, { diff --git a/src/atoms/invitationAtoms.ts b/src/atoms/invitationAtoms.ts index 00360eb94..ff62ae7f9 100644 --- a/src/atoms/invitationAtoms.ts +++ b/src/atoms/invitationAtoms.ts @@ -31,6 +31,7 @@ export const invitationListAtom = atomWithStorage('invite_list // Current invitation code from URL export const invitationCodeAtom = atomWithStorage('invite_code', undefined); +export const xInviteCodeAtom = atomWithStorage('x_invite_code', undefined); // Claimed invitations cache - stores claimer info export interface ClaimedInfo { diff --git a/src/components/modals/ProfileEditModal.tsx b/src/components/modals/ProfileEditModal.tsx index 194597287..a7904ae6e 100644 --- a/src/components/modals/ProfileEditModal.tsx +++ b/src/components/modals/ProfileEditModal.tsx @@ -18,6 +18,7 @@ import { getXCallbackRedirectUri, storeXOAuthPKCE, } from '@/utils/xOAuth'; +import { getStoredXInviteCode } from '@/utils/xInvite'; import { useQueryClient } from '@tanstack/react-query'; import { Check } from 'lucide-react'; import AppSelect, { Item as AppSelectItem } from '@/components/inputs/AppSelect'; @@ -251,6 +252,7 @@ const ProfileEditModal = ({ const [xUsername, setXUsername] = useState(null); const [xSectionReady, setXSectionReady] = useState(false); const [availableChainNames, setAvailableChainNames] = useState([]); + const xInviteCode = getStoredXInviteCode(); const xSectionRef = useRef(null); const connectXButtonRef = useRef(null); @@ -539,6 +541,11 @@ const ProfileEditModal = ({ )} {xSectionReady && !hasXVerified && ( <> + {xInviteCode && ( +
+ You were invited by a friend. Connect X to complete the invite mission. +
+ )}

{t('messages.connectXHint')}

diff --git a/src/features/trending/components/Invitation/CollectRewardsCard.tsx b/src/features/trending/components/Invitation/CollectRewardsCard.tsx index abb1c5de0..dfad1140d 100644 --- a/src/features/trending/components/Invitation/CollectRewardsCard.tsx +++ b/src/features/trending/components/Invitation/CollectRewardsCard.tsx @@ -259,7 +259,7 @@ const CollectRewardsCard = () => { } if (!thresholdReached) return 'Not eligible yet'; if (accumulatedRewardsAe.lte(Decimal.ZERO)) return 'No rewards yet'; - return 'Collect rewards'; + return 'Collect affiliate rewards'; }, [collectingReward, thresholdReached, accumulatedRewardsAe]); return ( @@ -270,7 +270,7 @@ const CollectRewardsCard = () => { πŸ’°

- Collect your rewards + Collect Affiliate Rewards

@@ -279,7 +279,7 @@ const CollectRewardsCard = () => { {/* Description - Left Side */}

- Rewards accumulate as your direct invitees participate in token sales. You can withdraw once + Affiliate rewards accumulate as your direct invitees participate in trading. You can withdraw once {' '} {MIN_INVITEES} @@ -295,7 +295,7 @@ const CollectRewardsCard = () => { AE {' '} - (cumulative). + (cumulative). Affiliate payout rate is 0.5%.

Note: eligibility and rewards depend on on-chain activity and are not guaranteed. diff --git a/src/features/trending/components/Invitation/InvitationList.tsx b/src/features/trending/components/Invitation/InvitationList.tsx index 1f0b828f5..e31a49fac 100644 --- a/src/features/trending/components/Invitation/InvitationList.tsx +++ b/src/features/trending/components/Invitation/InvitationList.tsx @@ -158,7 +158,7 @@ const InvitationList = () => { return (

-

{t('invitations.yourInvitations')}

+

Your Affiliate Invitations

{loading && ( diff --git a/src/features/trending/components/Invitation/InviteAndEarnCard.tsx b/src/features/trending/components/Invitation/InviteAndEarnCard.tsx index d6f0fbc8a..daeba6752 100644 --- a/src/features/trending/components/Invitation/InviteAndEarnCard.tsx +++ b/src/features/trending/components/Invitation/InviteAndEarnCard.tsx @@ -131,21 +131,21 @@ const InviteAndEarnCard = ({ 🎯

- Generate Invites + Generate Affiliate Links

{/* Description - Left Side */}

- Create invite links by funding a one-time AE reward per invite. Each + Create affiliate links by funding a one-time AE reward per invite. Each link contains a secret code; when someone opens the link and claims it, they receive the funded reward and the invitation is marked as used.

You can generate multiple links at once and share them with friends - or your community. You can also revoke an invite before it’s claimed. + or your community. You can also revoke an affiliate invite before it’s claimed.

Important: save your links before closing the popup. The secret code @@ -163,7 +163,7 @@ const InviteAndEarnCard = ({ htmlFor="amount" className="text-xs md:text-sm font-semibold text-slate-400 tracking-wider break-words" > - Amount per invite (AE) + Amount per affiliate invite (AE) { + const parsedFromUrl = parseXInviteCodeFromUrl(new URL(window.location.href)); + if (parsedFromUrl) { + setXInviteCode(parsedFromUrl); + storeXInviteCode(parsedFromUrl); + } + const { hash } = location; if (hash) { const hashParsed = new URLSearchParams(hash.replace('#', '')); @@ -296,7 +305,7 @@ export function useInvitations() { navigate('/', { replace: true }); } } - }, [location, location.hash, navigate, setInvitationCode]); + }, [location, location.hash, navigate, setInvitationCode, setXInviteCode]); // Refresh data when active account changes or refresh is triggered useEffect(() => { diff --git a/src/hooks/__tests__/useXInviteFlow.test.ts b/src/hooks/__tests__/useXInviteFlow.test.ts new file mode 100644 index 000000000..085337e5e --- /dev/null +++ b/src/hooks/__tests__/useXInviteFlow.test.ts @@ -0,0 +1,100 @@ +import { renderHook } from '@testing-library/react'; +import { + beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { useXInviteFlow } from '@/hooks/useXInviteFlow'; + +const mockCreateXInviteChallenge = vi.fn(); +const mockCreateXInvite = vi.fn(); +const mockBindXInvite = vi.fn(); +const mockGetXInviteProgress = vi.fn(); +const mockSignMessage = vi.fn(); +const mockVerifyMessage = vi.fn(() => true); + +vi.mock('@aeternity/aepp-sdk', () => ({ + verifyMessage: (...args: any[]) => mockVerifyMessage(...args), +})); + +vi.mock('@/api/backend', () => ({ + SuperheroApi: { + createXInviteChallenge: (...args: any[]) => mockCreateXInviteChallenge(...args), + createXInvite: (...args: any[]) => mockCreateXInvite(...args), + bindXInvite: (...args: any[]) => mockBindXInvite(...args), + getXInviteProgress: (...args: any[]) => mockGetXInviteProgress(...args), + }, +})); + +vi.mock('@/hooks/useAeSdk', () => ({ + useAeSdk: () => ({ + activeAccount: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', + aeSdk: { + signMessage: (...args: any[]) => mockSignMessage(...args), + }, + }), +})); + +describe('useXInviteFlow', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockVerifyMessage.mockReturnValue(true); + mockCreateXInviteChallenge.mockResolvedValue({ + nonce: 'nonce_1', + expires_at: 123456, + message: 'please sign this message', + }); + mockCreateXInvite.mockResolvedValue({ + code: 'abc123', + invite_link: 'https://api.superhero.com/invite/abc123', + }); + mockBindXInvite.mockResolvedValue(undefined); + mockGetXInviteProgress.mockResolvedValue({ + inviter_address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', + verified_friends_count: 2, + goal: 10, + remaining_to_goal: 8, + milestone_reward_status: 'pending', + milestone_reward_tx_hash: null, + }); + mockSignMessage.mockResolvedValue('0xdeadbeef'); + }); + + it('generates invite link with challenge + signature flow', async () => { + const { result } = renderHook(() => useXInviteFlow()); + + const link = await result.current.generateInviteLink(); + + expect(mockCreateXInviteChallenge).toHaveBeenCalledWith({ + address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', + purpose: 'create', + }); + expect(mockSignMessage).toHaveBeenCalledWith('please sign this message', { onAccount: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g' }); + expect(mockCreateXInvite).toHaveBeenCalledWith({ + inviter_address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', + challenge_nonce: 'nonce_1', + challenge_expires_at: '123456', + signature_hex: 'deadbeef', + }); + expect(link.code).toBe('abc123'); + expect(link.frontend_invite_link).toContain('?xInvite=abc123'); + expect(link.backend_invite_link).toBe('https://api.superhero.com/invite/abc123'); + }); + + it('binds invite for user B with challenge + signature flow', async () => { + const { result } = renderHook(() => useXInviteFlow()); + + await result.current.bindInviteForUserB('code_bind_1', 'ak_invitee'); + + expect(mockCreateXInviteChallenge).toHaveBeenCalledWith({ + address: 'ak_invitee', + purpose: 'bind', + code: 'code_bind_1', + }); + expect(mockBindXInvite).toHaveBeenCalledWith('code_bind_1', { + invitee_address: 'ak_invitee', + challenge_nonce: 'nonce_1', + challenge_expires_at: '123456', + signature_hex: 'deadbeef', + }); + }); +}); + diff --git a/src/hooks/useXInviteFlow.ts b/src/hooks/useXInviteFlow.ts new file mode 100644 index 000000000..900453eae --- /dev/null +++ b/src/hooks/useXInviteFlow.ts @@ -0,0 +1,199 @@ +import { useCallback } from 'react'; +import { verifyMessage } from '@aeternity/aepp-sdk'; +import { + SuperheroApi, + type XInviteChallengePurpose, + type XInviteProgressResponse, +} from '@/api/backend'; +import { useAeSdk } from '@/hooks/useAeSdk'; +import { buildFrontendXInviteLink } from '@/utils/xInvite'; + +function isHexLike(value: string): boolean { + return /^[0-9a-fA-F]+$/u.test(value); +} + +function toHex(value: Uint8Array): string { + return Array.from(value) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function hexToBytes(value: string): Uint8Array { + const normalized = value.startsWith('0x') ? value.slice(2) : value; + if (!isHexLike(normalized) || normalized.length % 2 !== 0) { + throw new Error('Invalid hex signature format'); + } + const out = new Uint8Array(normalized.length / 2); + for (let i = 0; i < normalized.length; i += 2) { + out[i / 2] = parseInt(normalized.slice(i, i + 2), 16); + } + return out; +} + +function normalizeSignatureHex(raw: unknown): string | undefined { + if (!raw) return undefined; + if (raw instanceof Uint8Array) return toHex(raw); + if (typeof raw === 'string') { + const normalized = raw.startsWith('0x') ? raw.slice(2) : raw; + return isHexLike(normalized) ? normalized.toLowerCase() : undefined; + } + if (typeof raw === 'object') { + const maybeObject = raw as Record; + const fromKnownFields = normalizeSignatureHex( + maybeObject.signature_hex + ?? maybeObject.signatureHex + ?? maybeObject.signature + ?? maybeObject.raw, + ); + if (fromKnownFields) return fromKnownFields; + } + return undefined; +} + +function debugInviteSignature( + stage: 'create' | 'bind', + details: Record, +) { + // Keep this log concise but diagnostic: enough to compare FE vs BE verification inputs. + // eslint-disable-next-line no-console + console.error(`[x-invite:${stage}] signature debug`, details); +} + +export function useXInviteFlow() { + const { aeSdk, activeAccount } = useAeSdk(); + + const signMessageHex = useCallback(async (message: string, address?: string): Promise => { + const signer: any = aeSdk as any; + if (!signer) { + throw new Error('Wallet is not connected'); + } + + const signersToTry: Array<() => Promise> = []; + signersToTry.push(() => signer.signMessage(message, { onAccount: address })); + signersToTry.push(() => signer.signMessage(message)); + + let lastError: unknown; + for (const signerAttempt of signersToTry) { + try { + const result = await signerAttempt(); + const signatureHex = normalizeSignatureHex(result); + if (!signatureHex) continue; + if (address) { + const isValid = verifyMessage(message, hexToBytes(signatureHex), address); + if (!isValid) { + throw new Error('Signed message does not match inviter wallet address'); + } + } + return signatureHex; + } catch (error) { + lastError = error; + } + } + + if (lastError instanceof Error) { + if (lastError.message.includes('signMessage is not a function')) { + throw new Error('Connected signer does not support message signing. Reconnect wallet and try again.'); + } + throw lastError; + } + throw new Error('Wallet did not return a valid message signature'); + }, [aeSdk]); + + const requestChallenge = useCallback(async ( + address: string, + purpose: XInviteChallengePurpose, + code?: string, + ) => SuperheroApi.createXInviteChallenge({ + address, + purpose, + ...(code ? { code } : {}), + }), []); + + const generateInviteLink = useCallback(async ( + address?: string, + ): Promise<{ code: string; frontend_invite_link: string; backend_invite_link: string }> => { + const signerAddress = address || activeAccount; + if (!signerAddress) { + throw new Error('Missing wallet address'); + } + const challenge = await requestChallenge(signerAddress, 'create'); + const signatureHex = await signMessageHex(challenge.message, signerAddress); + const payload = { + inviter_address: signerAddress, + challenge_nonce: challenge.nonce, + challenge_expires_at: String(challenge.expires_at), + signature_hex: signatureHex, + }; + try { + const invite = await SuperheroApi.createXInvite(payload); + return { + code: invite.code, + frontend_invite_link: buildFrontendXInviteLink(invite.code), + backend_invite_link: invite.invite_link, + }; + } catch (error) { + debugInviteSignature('create', { + inviter_address: signerAddress, + challenge_nonce: challenge.nonce, + challenge_expires_at: String(challenge.expires_at), + challenge_message_preview: challenge.message.slice(0, 120), + challenge_message_length: challenge.message.length, + signature_hex_prefix: signatureHex.slice(0, 16), + signature_hex_length: signatureHex.length, + local_verify_result: verifyMessage(challenge.message, hexToBytes(signatureHex), signerAddress), + error_message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, [activeAccount, requestChallenge, signMessageHex]); + + const loadInviteProgress = useCallback(async (address?: string): Promise => { + const targetAddress = address || activeAccount; + if (!targetAddress) { + throw new Error('Missing wallet address'); + } + return SuperheroApi.getXInviteProgress(targetAddress); + }, [activeAccount]); + + const bindInviteForUserB = useCallback(async (inviteCode: string, address?: string): Promise => { + const inviteeAddress = address || activeAccount; + if (!inviteeAddress) { + throw new Error('Missing wallet address'); + } + if (!inviteCode?.trim()) { + throw new Error('Missing invite code'); + } + const challenge = await requestChallenge(inviteeAddress, 'bind', inviteCode); + const signatureHex = await signMessageHex(challenge.message, inviteeAddress); + const payload = { + invitee_address: inviteeAddress, + challenge_nonce: challenge.nonce, + challenge_expires_at: String(challenge.expires_at), + signature_hex: signatureHex, + }; + try { + await SuperheroApi.bindXInvite(inviteCode, payload); + } catch (error) { + debugInviteSignature('bind', { + invitee_address: inviteeAddress, + invite_code: inviteCode, + challenge_nonce: challenge.nonce, + challenge_expires_at: String(challenge.expires_at), + challenge_message_preview: challenge.message.slice(0, 120), + challenge_message_length: challenge.message.length, + signature_hex_prefix: signatureHex.slice(0, 16), + signature_hex_length: signatureHex.length, + local_verify_result: verifyMessage(challenge.message, hexToBytes(signatureHex), inviteeAddress), + error_message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, [activeAccount, requestChallenge, signMessageHex]); + + return { + generateInviteLink, + loadInviteProgress, + bindInviteForUserB, + }; +} + diff --git a/src/utils/xInvite.ts b/src/utils/xInvite.ts new file mode 100644 index 000000000..084f3d016 --- /dev/null +++ b/src/utils/xInvite.ts @@ -0,0 +1,56 @@ +const X_INVITE_QUERY_KEY = 'xInvite'; +const LEGACY_INVITE_HASH_KEY = 'invite_code'; +const LS_X_INVITE_CODE_KEY = 'x_invite_code'; + +function normalizeInviteCode(value: string | null | undefined): string | undefined { + if (!value) return undefined; + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; +} + +function isLegacyAffiliateSecret(code: string): boolean { + // Old affiliate links store a private key-like value in #invite_code. + return code.startsWith('bb_') || code.startsWith('sk_'); +} + +export function parseXInviteCodeFromUrl(url: URL): string | undefined { + const queryCode = normalizeInviteCode(url.searchParams.get(X_INVITE_QUERY_KEY)); + if (queryCode) return queryCode; + + const hashValue = url.hash.startsWith('#') ? url.hash.slice(1) : url.hash; + const hashParams = new URLSearchParams(hashValue); + const legacyCode = normalizeInviteCode(hashParams.get(LEGACY_INVITE_HASH_KEY)); + if (!legacyCode) return undefined; + if (isLegacyAffiliateSecret(legacyCode)) return undefined; + return legacyCode; +} + +export function parseXInviteCodeFromWindow(): string | undefined { + if (typeof window === 'undefined') return undefined; + return parseXInviteCodeFromUrl(new URL(window.location.href)); +} + +export function storeXInviteCode(code: string | undefined) { + if (typeof window === 'undefined') return; + if (!code) { + localStorage.removeItem(LS_X_INVITE_CODE_KEY); + return; + } + localStorage.setItem(LS_X_INVITE_CODE_KEY, code); +} + +export function getStoredXInviteCode(): string | undefined { + if (typeof window === 'undefined') return undefined; + return normalizeInviteCode(localStorage.getItem(LS_X_INVITE_CODE_KEY)); +} + +export function clearStoredXInviteCode() { + storeXInviteCode(undefined); +} + +export function buildFrontendXInviteLink(code: string): string { + const safeCode = encodeURIComponent(code.trim()); + if (typeof window === 'undefined') return `/?xInvite=${safeCode}`; + return `${window.location.origin}/?xInvite=${safeCode}`; +} + diff --git a/src/views/ProfileXCallback.tsx b/src/views/ProfileXCallback.tsx index 80c8c235c..628d30f81 100644 --- a/src/views/ProfileXCallback.tsx +++ b/src/views/ProfileXCallback.tsx @@ -4,7 +4,14 @@ import { useTranslation } from 'react-i18next'; import { SuperheroApi } from '@/api/backend'; import { useAeSdk } from '@/hooks/useAeSdk'; import { useProfile } from '@/hooks/useProfile'; +import { useXInviteFlow } from '@/hooks/useXInviteFlow'; import { getAndClearXOAuthPKCE, isOurOAuthState } from '@/utils/xOAuth'; +import { + clearStoredXInviteCode, + getStoredXInviteCode, + parseXInviteCodeFromWindow, + storeXInviteCode, +} from '@/utils/xInvite'; const ConfirmWalletStep = ({ address, @@ -61,10 +68,14 @@ const ProfileXCallback = () => { const navigate = useNavigate(); const { t } = useTranslation('common'); const { activeAccount, addStaticAccount } = useAeSdk(); + const { bindInviteForUserB } = useXInviteFlow(); const [status, setStatus] = useState<'loading' | 'confirm_wallet' | 'done' | 'error'>('loading'); const [errorMessage, setErrorMessage] = useState(null); const [address, setAddress] = useState(null); const [attestation, setAttestation] = useState(null); + const [inviteCode, setInviteCode] = useState(null); + const [bindStatus, setBindStatus] = useState<'idle' | 'binding' | 'bound' | 'failed'>('idle'); + const [bindError, setBindError] = useState(null); const startedRef = useRef(false); useEffect(() => { @@ -91,9 +102,31 @@ const ProfileXCallback = () => { (async () => { try { + const parsedInviteCode = parseXInviteCodeFromWindow(); + if (parsedInviteCode) { + storeXInviteCode(parsedInviteCode); + } + const activeInviteCode = parsedInviteCode || getStoredXInviteCode() || null; + setInviteCode(activeInviteCode); + if (!activeAccount || activeAccount !== stored.address) { await addStaticAccount(stored.address); } + + if (activeInviteCode) { + setBindStatus('binding'); + setBindError(null); + try { + await bindInviteForUserB(activeInviteCode, stored.address); + setBindStatus('bound'); + clearStoredXInviteCode(); + } catch (bindErr: any) { + console.error('[x-callback] invite bind failed', bindErr); + setBindStatus('failed'); + setBindError(bindErr?.message || t('messages.failedToUpdateProfile')); + } + } + const att = await SuperheroApi.createXAttestationFromCode( stored.address, code, @@ -112,10 +145,26 @@ const ProfileXCallback = () => { })(); // Intentionally run only once per page load. Re-running after wallet state changes // would fail because PKCE state is consumed by getAndClearXOAuthPKCE(). - }, [searchParams, t, activeAccount, addStaticAccount]); + }, [searchParams, t, activeAccount, addStaticAccount, bindInviteForUserB]); return (

+ {inviteCode && ( +
+
You were invited by a friend
+
+ {bindStatus === 'binding' && 'Binding your invite before X verification...'} + {bindStatus === 'bound' && 'Invite linked successfully. Continuing with X verification.'} + {bindStatus === 'failed' && ( + <> + Could not bind invite automatically. You can still verify X. + {bindError ? ` (${bindError})` : ''} + + )} + {bindStatus === 'idle' && 'Preparing your verification flow...'} +
+
+ )} {status === 'loading' && (

{t('messages.xCallbackExchanging')}

)} diff --git a/src/views/Trendminer/Invite.tsx b/src/views/Trendminer/Invite.tsx index c916786d5..b47267bed 100644 --- a/src/views/Trendminer/Invite.tsx +++ b/src/views/Trendminer/Invite.tsx @@ -1,9 +1,9 @@ -/* eslint-disable - react/function-component-definition, - react/button-has-type, - no-empty -*/ -import { useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; import { CollectRewardsCard, InvitationList, @@ -11,111 +11,293 @@ import { } from '../../features/trending/components/Invitation'; import Shell from '../../components/layout/Shell'; import { useAeSdk } from '../../hooks'; +import { useXInviteFlow } from '../../hooks/useXInviteFlow'; + +const REFERRAL_GOAL = 10; +const SHARE_GOAL = 10; + +const CircleMazeProgress = ({ + total, + done, +}: { + total: number; + done: number; +}) => { + const safeDone = Math.max(0, Math.min(total, done)); + const rings = Math.max(total, 1); + const size = 230; + const center = size / 2; + const startRadius = 28; + const radiusStep = 7.5; -export default function Invite() { + return ( +
+
+
+ {safeDone} + / + {total} +
+
+ {Math.max(0, total - safeDone)} + {' '} + steps remaining +
+
+ πŸ† + Milestone reward +
+
+ +
+ + {Array.from({ length: rings }).map((_, index) => { + const radius = startRadius + (rings - 1 - index) * radiusStep; + const reached = index < safeDone; + return ( + + + {reached && ( + + )} + + ); + })} + + + + + + + + +
+
+ {safeDone} + / + {total} +
+
Steps
+
+
+
+ ); +}; + +const Invite = () => { const { activeAccount } = useAeSdk(); - const [showInfo, setShowInfo] = useState(() => { + const { generateInviteLink, loadInviteProgress } = useXInviteFlow(); + const [creatingInvite, setCreatingInvite] = useState(false); + const [frontendInviteLink, setFrontendInviteLink] = useState(null); + const [backendInviteLink, setBackendInviteLink] = useState(null); + const [inviteCode, setInviteCode] = useState(null); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState<{ + verified_friends_count: number; + goal: number; + milestone_reward_status: 'not_started' | 'pending' | 'paid' | 'failed'; + } | null>(null); + + const refreshProgress = useCallback(async () => { + if (!activeAccount) return; + try { + const data = await loadInviteProgress(activeAccount); + setProgress({ + verified_friends_count: data.verified_friends_count || 0, + goal: data.goal || REFERRAL_GOAL, + milestone_reward_status: data.milestone_reward_status || 'not_started', + }); + } catch (err: any) { + setError((prev) => prev || err?.message || 'Failed to load invite progress'); + } + }, [activeAccount, loadInviteProgress]); + + useEffect(() => { + refreshProgress(); + }, [refreshProgress]); + + const referralsDone = progress?.verified_friends_count || 0; + const referralsGoal = progress?.goal || REFERRAL_GOAL; + const milestoneStatus = progress?.milestone_reward_status || 'not_started'; + + const milestoneBadgeClass = useMemo(() => { + switch (milestoneStatus) { + case 'paid': + return 'bg-emerald-500/20 border-emerald-400/40 text-emerald-200'; + case 'pending': + return 'bg-amber-500/20 border-amber-400/40 text-amber-200'; + case 'failed': + return 'bg-red-500/20 border-red-400/40 text-red-200'; + default: + return 'bg-white/10 border-white/20 text-white/80'; + } + }, [milestoneStatus]); + + const onGenerateInvite = useCallback(async () => { + if (!activeAccount) return; + setError(null); + setCreatingInvite(true); try { - return localStorage.getItem('invite_info_dismissed') !== '1'; - } catch { - return true; + const invite = await generateInviteLink(activeAccount); + setInviteCode(invite.code); + setFrontendInviteLink(invite.frontend_invite_link); + setBackendInviteLink(invite.backend_invite_link); + await refreshProgress(); + } catch (err: any) { + setError(err?.message || 'Failed to generate invite link'); + } finally { + setCreatingInvite(false); } - }); + }, [activeAccount, generateInviteLink, refreshProgress]); return ( -
- {/* Hero Section */} -
-

- - Invite & Earn - -
- Build your network, earn rewards -
+
+
+

+ Invite Missions

+
+ Complete milestones and unlock AE rewards +
- {/* Info Card */} - {showInfo && ( -
- {/* Close button - absolute positioned on all screen sizes for better space usage */} - - -
-
- πŸ’‘ -
-
-

- How it works -

-
-
-
- 1 -
-
- Generate invite links by funding a one-time AE reward per invite -
-
-
-
- 2 -
-
- Share links with friends and community -
-
-
-
- 3 -
-
- After 4 unique invitees buy tokens, you can withdraw accumulated rewards -
-
-
-
- 4 -
-
- Withdraw rewards anytime after eligibility -
-
+ +
+
+
+

Reward

+ + 50 AE + +
+

+ Connect Twitter (minimum 50 followers) to claim your first mission reward. +

+
+ +
+
+

Share

+ + 150 AE + +
+

+ Post 10/10 tweets mentioning the Superhero link or account. + This mission is layout-only for now. +

+ +
+ +
+
+

Referrals

+ + 200 AE + +
+

+ Invite 10 users and have them connect Twitter. +

+
+ + Progress: + {' '} + + {referralsDone} + / + {referralsGoal} + + + + {milestoneStatus} + +
+ + +
+ + {frontendInviteLink && ( + + )} +
+ {frontendInviteLink && ( +
+
+ Invite code: + {' '} + {inviteCode}
+
Frontend link (portable):
+
{frontendInviteLink}
+ {backendInviteLink && backendInviteLink !== frontendInviteLink && ( + <> +
Backend-provided link:
+
{backendInviteLink}
+ + )}
-
+ )} + {error && ( +
+ {error} +
+ )} + +
+ +
+
+

+ Affiliate +

+

+ Invite 3/3 users with AE amount. When they trade, you make 0.5%. +

- )} - {/* Main Action Cards */} -
- {/* Generate Invites Card */} - {/* Rewards Card */} + {activeAccount && }
- {/* User Invitations */} - {activeAccount && ( -
-

- Your Invitations -

- -
- )}
); -} +}; + +export default Invite; diff --git a/src/views/__tests__/ProfileXCallback.test.tsx b/src/views/__tests__/ProfileXCallback.test.tsx index 0a797ccfa..34f30a4b3 100644 --- a/src/views/__tests__/ProfileXCallback.test.tsx +++ b/src/views/__tests__/ProfileXCallback.test.tsx @@ -9,6 +9,11 @@ const mockCreateXAttestationFromCode = vi.fn(); const mockGetAndClearXOAuthPKCE = vi.fn(); const mockAddStaticAccount = vi.fn(); const mockCompleteXWithAttestation = vi.fn(); +const mockBindInviteForUserB = vi.fn(); +const mockParseXInviteCodeFromWindow = vi.fn(); +const mockGetStoredXInviteCode = vi.fn(); +const mockStoreXInviteCode = vi.fn(); +const mockClearStoredXInviteCode = vi.fn(); let mockActiveAccount = 'ak_other'; @@ -31,11 +36,24 @@ vi.mock('@/hooks/useProfile', () => ({ }), })); +vi.mock('@/hooks/useXInviteFlow', () => ({ + useXInviteFlow: () => ({ + bindInviteForUserB: (...args: any[]) => mockBindInviteForUserB(...args), + }), +})); + vi.mock('@/utils/xOAuth', () => ({ isOurOAuthState: () => true, getAndClearXOAuthPKCE: (...args: any[]) => mockGetAndClearXOAuthPKCE(...args), })); +vi.mock('@/utils/xInvite', () => ({ + parseXInviteCodeFromWindow: (...args: any[]) => mockParseXInviteCodeFromWindow(...args), + getStoredXInviteCode: (...args: any[]) => mockGetStoredXInviteCode(...args), + storeXInviteCode: (...args: any[]) => mockStoreXInviteCode(...args), + clearStoredXInviteCode: (...args: any[]) => mockClearStoredXInviteCode(...args), +})); + describe('ProfileXCallback', () => { beforeEach(() => { vi.clearAllMocks(); @@ -56,6 +74,9 @@ describe('ProfileXCallback', () => { }); mockCompleteXWithAttestation.mockResolvedValue('th_x'); + mockBindInviteForUserB.mockResolvedValue(undefined); + mockParseXInviteCodeFromWindow.mockReturnValue(undefined); + mockGetStoredXInviteCode.mockReturnValue(undefined); }); it('consumes PKCE storage only once even if wallet state causes rerender', async () => { @@ -85,4 +106,22 @@ describe('ProfileXCallback', () => { expect(mockCreateXAttestationFromCode).toHaveBeenCalledTimes(1); }); }); + + it('binds invite code before requesting X attestation', async () => { + mockGetStoredXInviteCode.mockReturnValue('invite_code_123'); + + render( + + + } /> + + , + ); + + await waitFor(() => { + expect(mockBindInviteForUserB).toHaveBeenCalledWith('invite_code_123', 'ak_test_1'); + expect(mockCreateXAttestationFromCode).toHaveBeenCalledTimes(1); + expect(mockClearStoredXInviteCode).toHaveBeenCalledTimes(1); + }); + }); }); From 87b642a03a41c083ff523f0c6dfad0be27f3283f Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 2 Mar 2026 11:41:24 +0400 Subject: [PATCH 2/5] feat: be able to run action using sdk on refresh --- src/components/modals/ProfileEditModal.tsx | 13 ++ .../__tests__/useWalletOperations.test.ts | 57 +++++ src/hooks/__tests__/useXInviteFlow.test.ts | 18 +- src/hooks/index.ts | 1 + src/hooks/useAeSdk.ts | 6 + src/hooks/useWalletConnect.ts | 39 ++++ src/hooks/useWalletOperations.ts | 212 ++++++++++++++++++ src/hooks/useXInviteFlow.ts | 65 +++--- src/utils/walletDeepLink.ts | 37 +++ src/views/ProfileXCallback.tsx | 13 +- src/views/UserProfile.tsx | 4 +- 11 files changed, 420 insertions(+), 45 deletions(-) create mode 100644 src/hooks/__tests__/useWalletOperations.test.ts create mode 100644 src/hooks/useWalletOperations.ts create mode 100644 src/utils/walletDeepLink.ts diff --git a/src/components/modals/ProfileEditModal.tsx b/src/components/modals/ProfileEditModal.tsx index a7904ae6e..7b3c94ddf 100644 --- a/src/components/modals/ProfileEditModal.tsx +++ b/src/components/modals/ProfileEditModal.tsx @@ -10,6 +10,7 @@ import { } from '@/api/backend'; import { CONFIG } from '@/config'; import { useAeSdk } from '@/hooks/useAeSdk'; +import { useWalletConnect } from '@/hooks/useWalletConnect'; import { useProfile } from '@/hooks/useProfile'; import { buildXAuthorizeUrl, @@ -243,6 +244,7 @@ const ProfileEditModal = ({ const [connectingX, setConnectingX] = useState(false); const { push } = useToast(); const { activeAccount } = useAeSdk(); + const { walletConnected, reconnectWalletSession } = useWalletConnect(); const queryClient = useQueryClient(); const [form, setForm] = useState(EMPTY_FORM); const [initialForm, setInitialForm] = useState(EMPTY_FORM); @@ -541,6 +543,13 @@ const ProfileEditModal = ({ )} {xSectionReady && !hasXVerified && ( <> + {!walletConnected && ( +
+ Wallet connection is required for invite bind. + {' '} + We will try to reconnect before opening X. +
+ )} {xInviteCode && (
You were invited by a friend. Connect X to complete the invite mission. @@ -560,6 +569,10 @@ const ProfileEditModal = ({ if (!targetAddr) return; setConnectingX(true); try { + const ready = await reconnectWalletSession(targetAddr); + if (!ready) { + throw new Error('Wallet session is not ready. Please connect wallet and try again.'); + } const redirectUri = getXCallbackRedirectUri(); const state = generateOAuthState(); const codeVerifier = generateCodeVerifier(); diff --git a/src/hooks/__tests__/useWalletOperations.test.ts b/src/hooks/__tests__/useWalletOperations.test.ts new file mode 100644 index 000000000..9a7953d11 --- /dev/null +++ b/src/hooks/__tests__/useWalletOperations.test.ts @@ -0,0 +1,57 @@ +import { renderHook } from '@testing-library/react'; +import { + beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { useWalletOperations } from '@/hooks/useWalletOperations'; + +const mockSignMessage = vi.fn(); +const mockReconnectWalletSession = vi.fn(); + +vi.mock('@/hooks/useAeSdk', () => ({ + useAeSdk: () => ({ + aeSdk: { + signMessage: (...args: any[]) => mockSignMessage(...args), + }, + activeAccount: 'ak_test_wallet', + }), +})); + +vi.mock('@/hooks/useWalletConnect', () => ({ + useWalletConnect: () => ({ + reconnectWalletSession: (...args: any[]) => mockReconnectWalletSession(...args), + }), +})); + +describe('useWalletOperations', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockReconnectWalletSession.mockResolvedValue(true); + }); + + it('uses sdk signing when wallet session is healthy', async () => { + mockSignMessage.mockResolvedValue('0xdeadbeef'); + const { result } = renderHook(() => useWalletOperations()); + + const signed = await result.current.signMessageWithFallback('hello', 'ak_test_wallet'); + + expect(signed).toEqual({ + signatureHex: 'deadbeef', + signerAddress: 'ak_test_wallet', + method: 'sdk', + }); + expect(mockReconnectWalletSession).not.toHaveBeenCalled(); + }); + + it('reconnects and retries sdk signing when initial signing fails', async () => { + mockSignMessage + .mockRejectedValueOnce(new Error('Wallet is not connected')) + .mockResolvedValueOnce('0xdeadbeef'); + const { result } = renderHook(() => useWalletOperations()); + + const signed = await result.current.signMessageWithFallback('hello', 'ak_test_wallet'); + + expect(mockReconnectWalletSession).toHaveBeenCalledWith('ak_test_wallet'); + expect(signed.method).toBe('reconnect'); + }); +}); + diff --git a/src/hooks/__tests__/useXInviteFlow.test.ts b/src/hooks/__tests__/useXInviteFlow.test.ts index 085337e5e..09a391f94 100644 --- a/src/hooks/__tests__/useXInviteFlow.test.ts +++ b/src/hooks/__tests__/useXInviteFlow.test.ts @@ -8,8 +8,8 @@ const mockCreateXInviteChallenge = vi.fn(); const mockCreateXInvite = vi.fn(); const mockBindXInvite = vi.fn(); const mockGetXInviteProgress = vi.fn(); -const mockSignMessage = vi.fn(); const mockVerifyMessage = vi.fn(() => true); +const mockSignMessageWithFallback = vi.fn(); vi.mock('@aeternity/aepp-sdk', () => ({ verifyMessage: (...args: any[]) => mockVerifyMessage(...args), @@ -27,9 +27,12 @@ vi.mock('@/api/backend', () => ({ vi.mock('@/hooks/useAeSdk', () => ({ useAeSdk: () => ({ activeAccount: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', - aeSdk: { - signMessage: (...args: any[]) => mockSignMessage(...args), - }, + }), +})); + +vi.mock('@/hooks/useWalletOperations', () => ({ + useWalletOperations: () => ({ + signMessageWithFallback: (...args: any[]) => mockSignMessageWithFallback(...args), }), })); @@ -55,7 +58,10 @@ describe('useXInviteFlow', () => { milestone_reward_status: 'pending', milestone_reward_tx_hash: null, }); - mockSignMessage.mockResolvedValue('0xdeadbeef'); + mockSignMessageWithFallback.mockResolvedValue({ + signatureHex: '0xdeadbeef', + method: 'sdk', + }); }); it('generates invite link with challenge + signature flow', async () => { @@ -67,7 +73,7 @@ describe('useXInviteFlow', () => { address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', purpose: 'create', }); - expect(mockSignMessage).toHaveBeenCalledWith('please sign this message', { onAccount: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g' }); + expect(mockSignMessageWithFallback).toHaveBeenCalledWith('please sign this message', 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g'); expect(mockCreateXInvite).toHaveBeenCalledWith({ inviter_address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', challenge_nonce: 'nonce_1', diff --git a/src/hooks/index.ts b/src/hooks/index.ts index ed316a10c..2a1c6572b 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -14,6 +14,7 @@ export { useChart } from './useChart'; export { useOwnedTokens } from './useOwnedTokens'; export { usePortfolioValue } from './usePortfolioValue'; export { useIsMobile } from './useIsMobile'; +export { useWalletOperations } from './useWalletOperations'; // Re-export atoms for direct usage if needed export * from '../atoms/walletAtoms'; diff --git a/src/hooks/useAeSdk.ts b/src/hooks/useAeSdk.ts index ef6adcf71..1fe70e9fe 100644 --- a/src/hooks/useAeSdk.ts +++ b/src/hooks/useAeSdk.ts @@ -29,6 +29,11 @@ export const useAeSdk = () => { return context.staticAeSdk; }, [walletConnected, walletInfo, activeAccount, context.aeSdk, context.staticAeSdk]); + const isWalletSessionReady = useMemo( + () => Boolean(context.sdkInitialized && walletConnected && walletInfo && activeAccount), + [context.sdkInitialized, walletConnected, walletInfo, activeAccount], + ); + /** * Ensure that when we're using staticAeSdk and have an activeAccount, * that account is properly added to the SDK. @@ -52,6 +57,7 @@ export const useAeSdk = () => { return { ...context, sdk, + isWalletSessionReady, // Override activeAccount from context with the atom value to ensure reactivity activeAccount, }; diff --git a/src/hooks/useWalletConnect.ts b/src/hooks/useWalletConnect.ts index 9acd93a63..6139ab3d0 100644 --- a/src/hooks/useWalletConnect.ts +++ b/src/hooks/useWalletConnect.ts @@ -29,6 +29,7 @@ export function useWalletConnect() { const scanConnectionRef = useRef(null); const scanPromiseRef = useRef | null>(null); const reconnectionAttemptedRef = useRef(false); + const connectOperationRef = useRef | null>(null); const [walletInfo, setWalletInfo] = useAtom(walletInfoAtom); const [scanningForAccounts, setScanningForAccounts] = useAtom(scanningForAccountsAtom); @@ -139,6 +140,8 @@ export function useWalletConnect() { // eslint-disable-next-line consistent-return async function connectWallet() { + if (connectOperationRef.current) return connectOperationRef.current; + const run = (async () => { // when trying to connect to the wallet all states should be reset // and sdk should be disconnected setWalletConnected(false); @@ -170,6 +173,14 @@ export function useWalletConnect() { disconnectWallet(); } setConnectingWallet(false); + return null; + })(); + connectOperationRef.current = run; + try { + return await run; + } finally { + connectOperationRef.current = null; + } } connectWalletRef.current = connectWallet; @@ -314,6 +325,33 @@ export function useWalletConnect() { } }, []); + const reconnectWalletSession = useCallback(async (expectedAddress?: string) => { + if (connectingWalletRef.current) return false; + if (walletConnectedRef.current && activeAccountRef.current) { + if (!expectedAddress || activeAccountRef.current === expectedAddress) return true; + } + + try { + await connectWalletRef.current?.(); + } catch { + // swallow and evaluate final state below + } + + if (walletConnectedRef.current && activeAccountRef.current) { + if (!expectedAddress || activeAccountRef.current === expectedAddress) return true; + } + + if (expectedAddress) { + try { + await addStaticAccount(expectedAddress); + } catch { + // + } + } + + return !!walletConnectedRef.current; + }, [addStaticAccount]); + // Monitor wallet connection health - if walletInfo exists but connection is lost, clear state useEffect(() => { if (!aeSdk || !walletConnected || !walletInfo) return undefined; @@ -346,6 +384,7 @@ export function useWalletConnect() { return { walletInfo, attemptReconnection, + reconnectWalletSession, connectWallet, deepLinkWalletConnect, disconnectWallet, diff --git a/src/hooks/useWalletOperations.ts b/src/hooks/useWalletOperations.ts new file mode 100644 index 000000000..b2fa84911 --- /dev/null +++ b/src/hooks/useWalletOperations.ts @@ -0,0 +1,212 @@ +import { useCallback } from 'react'; +import { useAeSdk } from '@/hooks/useAeSdk'; +import { useWalletConnect } from '@/hooks/useWalletConnect'; +import { + buildSignMessageDeepLink, + parseSignMessageCallback, +} from '@/utils/walletDeepLink'; + +type SignMethod = 'sdk' | 'reconnect' | 'deeplink'; + +type SignMessageResult = { + signatureHex: string; + signerAddress?: string; + method: SignMethod; +}; + +function isHex(value: string): boolean { + return /^[0-9a-fA-F]+$/u.test(value); +} + +function bytesToHex(value: Uint8Array): string { + return Array.from(value) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); +} + +function normalizeSignatureHex(value: unknown): string | undefined { + if (!value) return undefined; + if (value instanceof Uint8Array) return bytesToHex(value); + if (typeof value === 'string') { + const stripped = value.startsWith('0x') ? value.slice(2) : value; + if (isHex(stripped)) return stripped.toLowerCase(); + + // Deep-link callback might return base64-encoded signature. + try { + const decoded = atob(value); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i += 1) { + bytes[i] = decoded.charCodeAt(i); + } + return bytesToHex(bytes); + } catch { + return undefined; + } + } + if (typeof value === 'object') { + const record = value as Record; + return normalizeSignatureHex( + record.signature_hex + ?? record.signatureHex + ?? record.signature + ?? record.raw, + ); + } + return undefined; +} + +function shouldReconnectForError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return [ + 'not connected', + 'disconnected', + 'signMessage is not a function', + 'resolveAccount', + 'do not have access to account', + 'have no access to account', + 'access to account', + 'wallet', + ].some((marker) => message.toLowerCase().includes(marker.toLowerCase())); +} + +export function useWalletOperations() { + const { aeSdk, activeAccount } = useAeSdk(); + const { reconnectWalletSession } = useWalletConnect(); + + const ensureWalletSession = useCallback(async (expectedAddress?: string) => { + const expected = expectedAddress || activeAccount; + const connected = await reconnectWalletSession(expected); + return connected; + }, [activeAccount, reconnectWalletSession]); + + const signMessageViaDeepLink = useCallback(async ( + message: string, + expectedAddress?: string, + ): Promise => { + const requestId = Math.random().toString(36).slice(2); + const url = buildSignMessageDeepLink({ message, requestId }); + + const popup = window.open(url, '_blank', 'name=Superhero Wallet,width=362,height=594,toolbar=false,location=false,menubar=false,popup'); + if (!popup) { + throw new Error('Wallet popup was blocked by the browser'); + } + + const startedAt = Date.now(); + const timeoutMs = 120000; + + return new Promise((resolve, reject) => { + const interval = window.setInterval(() => { + if (Date.now() - startedAt > timeoutMs) { + window.clearInterval(interval); + popup.close(); + reject(new Error('Wallet message signing timed out')); + return; + } + + if (popup.closed) { + window.clearInterval(interval); + reject(new Error('Wallet message signing was cancelled')); + return; + } + + try { + const href = popup.location.href; + if (!href.startsWith(window.location.origin)) return; + + const callback = parseSignMessageCallback(href); + if (!callback || callback.requestId !== requestId) return; + + if (callback.cancelled) { + window.clearInterval(interval); + popup.close(); + reject(new Error('Wallet message signing was cancelled')); + return; + } + + const signatureHex = normalizeSignatureHex(callback.signature || undefined); + const signerAddress = callback.address; + + if (!signatureHex) return; + if (expectedAddress && signerAddress && signerAddress !== expectedAddress) { + window.clearInterval(interval); + popup.close(); + reject(new Error(`Wallet signed with unexpected account: ${signerAddress}`)); + return; + } + + window.clearInterval(interval); + popup.close(); + resolve({ + signatureHex, + signerAddress, + method: 'deeplink', + }); + } catch { + // Cross-origin during wallet screens; ignore until callback returns to our domain. + } + }, 350); + }); + }, []); + + const signMessageWithFallback = useCallback(async ( + message: string, + expectedAddress?: string, + ): Promise => { + const signer: any = aeSdk as any; + const targetAddress = expectedAddress || activeAccount; + if (!targetAddress) { + throw new Error('Missing signer address'); + } + + const signWithSdk = async (): Promise => { + const result = await signer.signMessage(message, { onAccount: targetAddress }); + const normalized = normalizeSignatureHex(result); + if (!normalized) throw new Error('Wallet did not return a valid signature'); + return normalized; + }; + + try { + const signatureHex = await signWithSdk(); + return { signatureHex, signerAddress: targetAddress, method: 'sdk' }; + } catch (err) { + if (!shouldReconnectForError(err)) { + throw err; + } + } + + const reconnected = await ensureWalletSession(targetAddress); + if (reconnected) { + try { + const signatureHex = await signWithSdk(); + return { signatureHex, signerAddress: targetAddress, method: 'reconnect' }; + } catch { + // Fall through to deep-link path + } + } + + return signMessageViaDeepLink(message, targetAddress); + }, [aeSdk, activeAccount, ensureWalletSession, signMessageViaDeepLink]); + + const signTransactionWithFallback = useCallback(async ( + transaction: string, + expectedAddress?: string, + options?: Record, + ) => { + const signer: any = aeSdk as any; + try { + return await signer.signTransaction(transaction, options); + } catch (error) { + if (!shouldReconnectForError(error)) throw error; + const reconnected = await ensureWalletSession(expectedAddress); + if (!reconnected) throw error; + return signer.signTransaction(transaction, options); + } + }, [aeSdk, ensureWalletSession]); + + return { + ensureWalletSession, + signMessageWithFallback, + signTransactionWithFallback, + }; +} + diff --git a/src/hooks/useXInviteFlow.ts b/src/hooks/useXInviteFlow.ts index 900453eae..7a11d3285 100644 --- a/src/hooks/useXInviteFlow.ts +++ b/src/hooks/useXInviteFlow.ts @@ -6,6 +6,7 @@ import { type XInviteProgressResponse, } from '@/api/backend'; import { useAeSdk } from '@/hooks/useAeSdk'; +import { useWalletOperations } from '@/hooks/useWalletOperations'; import { buildFrontendXInviteLink } from '@/utils/xInvite'; function isHexLike(value: string): boolean { @@ -59,45 +60,33 @@ function debugInviteSignature( console.error(`[x-invite:${stage}] signature debug`, details); } -export function useXInviteFlow() { - const { aeSdk, activeAccount } = useAeSdk(); - - const signMessageHex = useCallback(async (message: string, address?: string): Promise => { - const signer: any = aeSdk as any; - if (!signer) { - throw new Error('Wallet is not connected'); - } +type SignOutcome = { + signatureHex: string; + method: 'sdk' | 'reconnect' | 'deeplink'; +}; - const signersToTry: Array<() => Promise> = []; - signersToTry.push(() => signer.signMessage(message, { onAccount: address })); - signersToTry.push(() => signer.signMessage(message)); +export function useXInviteFlow() { + const { activeAccount } = useAeSdk(); + const { signMessageWithFallback } = useWalletOperations(); - let lastError: unknown; - for (const signerAttempt of signersToTry) { - try { - const result = await signerAttempt(); - const signatureHex = normalizeSignatureHex(result); - if (!signatureHex) continue; - if (address) { - const isValid = verifyMessage(message, hexToBytes(signatureHex), address); - if (!isValid) { - throw new Error('Signed message does not match inviter wallet address'); - } - } - return signatureHex; - } catch (error) { - lastError = error; - } + const signMessageHex = useCallback(async (message: string, address?: string): Promise => { + const targetAddress = address || activeAccount; + const result = await signMessageWithFallback(message, targetAddress); + const signatureHex = normalizeSignatureHex(result.signatureHex); + if (!signatureHex) { + throw new Error('Wallet did not return a valid message signature'); } - - if (lastError instanceof Error) { - if (lastError.message.includes('signMessage is not a function')) { - throw new Error('Connected signer does not support message signing. Reconnect wallet and try again.'); + if (targetAddress) { + const isValid = verifyMessage(message, hexToBytes(signatureHex), targetAddress); + if (!isValid) { + throw new Error('Signed message does not match inviter wallet address'); } - throw lastError; } - throw new Error('Wallet did not return a valid message signature'); - }, [aeSdk]); + return { + signatureHex, + method: result.method, + }; + }, [activeAccount, signMessageWithFallback]); const requestChallenge = useCallback(async ( address: string, @@ -117,7 +106,8 @@ export function useXInviteFlow() { throw new Error('Missing wallet address'); } const challenge = await requestChallenge(signerAddress, 'create'); - const signatureHex = await signMessageHex(challenge.message, signerAddress); + const signOutcome = await signMessageHex(challenge.message, signerAddress); + const { signatureHex } = signOutcome; const payload = { inviter_address: signerAddress, challenge_nonce: challenge.nonce, @@ -141,6 +131,7 @@ export function useXInviteFlow() { signature_hex_prefix: signatureHex.slice(0, 16), signature_hex_length: signatureHex.length, local_verify_result: verifyMessage(challenge.message, hexToBytes(signatureHex), signerAddress), + sign_method: signOutcome.method, error_message: error instanceof Error ? error.message : String(error), }); throw error; @@ -164,7 +155,8 @@ export function useXInviteFlow() { throw new Error('Missing invite code'); } const challenge = await requestChallenge(inviteeAddress, 'bind', inviteCode); - const signatureHex = await signMessageHex(challenge.message, inviteeAddress); + const signOutcome = await signMessageHex(challenge.message, inviteeAddress); + const { signatureHex } = signOutcome; const payload = { invitee_address: inviteeAddress, challenge_nonce: challenge.nonce, @@ -184,6 +176,7 @@ export function useXInviteFlow() { signature_hex_prefix: signatureHex.slice(0, 16), signature_hex_length: signatureHex.length, local_verify_result: verifyMessage(challenge.message, hexToBytes(signatureHex), inviteeAddress), + sign_method: signOutcome.method, error_message: error instanceof Error ? error.message : String(error), }); throw error; diff --git a/src/utils/walletDeepLink.ts b/src/utils/walletDeepLink.ts new file mode 100644 index 000000000..fb73c1c2f --- /dev/null +++ b/src/utils/walletDeepLink.ts @@ -0,0 +1,37 @@ +import { createDeepLinkUrl } from '@/utils/url'; + +export type SignMessageCallbackPayload = { + requestId: string; + signature?: string; + address?: string; + cancelled: boolean; +}; + +export function buildSignMessageDeepLink(params: { + message: string; + requestId: string; + callbackOrigin?: string; +}) { + const callbackOrigin = params.callbackOrigin || window.location.origin; + const successUrl = `${callbackOrigin}/?wallet_sign_req=${params.requestId}&wallet_sign_signature={signature}&wallet_sign_address={address}`; + const cancelUrl = `${callbackOrigin}/?wallet_sign_req=${params.requestId}&wallet_sign_cancelled=1`; + return createDeepLinkUrl({ + type: 'sign-message', + message: params.message, + 'x-success': successUrl, + 'x-cancel': cancelUrl, + }); +} + +export function parseSignMessageCallback(url: string): SignMessageCallbackPayload | null { + const params = new URL(url).searchParams; + const requestId = params.get('wallet_sign_req'); + if (!requestId) return null; + return { + requestId, + signature: params.get('wallet_sign_signature') || undefined, + address: params.get('wallet_sign_address') || undefined, + cancelled: params.get('wallet_sign_cancelled') === '1', + }; +} + diff --git a/src/views/ProfileXCallback.tsx b/src/views/ProfileXCallback.tsx index 628d30f81..eff522dc9 100644 --- a/src/views/ProfileXCallback.tsx +++ b/src/views/ProfileXCallback.tsx @@ -123,7 +123,18 @@ const ProfileXCallback = () => { } catch (bindErr: any) { console.error('[x-callback] invite bind failed', bindErr); setBindStatus('failed'); - setBindError(bindErr?.message || t('messages.failedToUpdateProfile')); + const rawMessage = bindErr?.message || ''; + if (rawMessage.toLowerCase().includes('not connected')) { + setBindError('Wallet session was stale during invite bind. Please retry bind later from invite link.'); + } else if (rawMessage.toLowerCase().includes('access to account')) { + setBindError( + 'Wallet account differs from the invite address during bind. Switch to the invited account and retry.', + ); + } else if (rawMessage.toLowerCase().includes('cancel')) { + setBindError('Invite bind was cancelled in wallet. You can retry later from invite link.'); + } else { + setBindError(rawMessage || t('messages.failedToUpdateProfile')); + } } } diff --git a/src/views/UserProfile.tsx b/src/views/UserProfile.tsx index 7b3dee792..dd6739fde 100644 --- a/src/views/UserProfile.tsx +++ b/src/views/UserProfile.tsx @@ -391,7 +391,7 @@ export default function UserProfile({ {/* Action buttons */}
- {(canEdit && false) ? ( + {canEdit && false ? (
- {(canEdit && !isXVerified && false) && ( + {canEdit && !isXVerified && false && (
diff --git a/src/hooks/__tests__/useXInviteFlow.test.ts b/src/hooks/__tests__/useXInviteFlow.test.ts index 09a391f94..2e0673238 100644 --- a/src/hooks/__tests__/useXInviteFlow.test.ts +++ b/src/hooks/__tests__/useXInviteFlow.test.ts @@ -7,7 +7,7 @@ import { useXInviteFlow } from '@/hooks/useXInviteFlow'; const mockCreateXInviteChallenge = vi.fn(); const mockCreateXInvite = vi.fn(); const mockBindXInvite = vi.fn(); -const mockGetXInviteProgress = vi.fn(); +const mockGetRewardsProgress = vi.fn(); const mockVerifyMessage = vi.fn(() => true); const mockSignMessageWithFallback = vi.fn(); @@ -20,7 +20,7 @@ vi.mock('@/api/backend', () => ({ createXInviteChallenge: (...args: any[]) => mockCreateXInviteChallenge(...args), createXInvite: (...args: any[]) => mockCreateXInvite(...args), bindXInvite: (...args: any[]) => mockBindXInvite(...args), - getXInviteProgress: (...args: any[]) => mockGetXInviteProgress(...args), + getRewardsProgress: (...args: any[]) => mockGetRewardsProgress(...args), }, })); @@ -50,13 +50,49 @@ describe('useXInviteFlow', () => { invite_link: 'https://api.superhero.com/invite/abc123', }); mockBindXInvite.mockResolvedValue(undefined); - mockGetXInviteProgress.mockResolvedValue({ - inviter_address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', - verified_friends_count: 2, - goal: 10, - remaining_to_goal: 8, - milestone_reward_status: 'pending', - milestone_reward_tx_hash: null, + mockGetRewardsProgress.mockResolvedValue({ + address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', + x_verification_reward: { + status: 'not_started', + x_username: null, + tx_hash: null, + retry_count: 0, + next_retry_at: null, + error: null, + }, + x_posting_reward: { + status: 'pending', + x_username: null, + x_user_id: null, + qualified_posts_count: 0, + threshold: 10, + remaining_to_goal: 10, + tx_hash: null, + retry_count: 0, + next_retry_at: null, + error: null, + }, + x_invite_reward: { + inviter_address: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', + verified_friends_count: 2, + goal: 10, + remaining_to_goal: 8, + milestone_reward_status: 'pending', + milestone_reward_tx_hash: null, + }, + affiliation: { + as_inviter: { + total_invitations: 0, + claimed_invitations: 0, + revoked_invitations: 0, + pending_invitations: 0, + total_amount_ae: 0, + }, + as_invitee: { + total_received_invitations: 0, + claimed_received_invitations: 0, + }, + }, }); mockSignMessageWithFallback.mockResolvedValue({ signatureHex: '0xdeadbeef', diff --git a/src/hooks/useXInviteFlow.ts b/src/hooks/useXInviteFlow.ts index 7a11d3285..0c5bf7710 100644 --- a/src/hooks/useXInviteFlow.ts +++ b/src/hooks/useXInviteFlow.ts @@ -3,7 +3,7 @@ import { verifyMessage } from '@aeternity/aepp-sdk'; import { SuperheroApi, type XInviteChallengePurpose, - type XInviteProgressResponse, + type RewardsProgressResponse, } from '@/api/backend'; import { useAeSdk } from '@/hooks/useAeSdk'; import { useWalletOperations } from '@/hooks/useWalletOperations'; @@ -138,12 +138,12 @@ export function useXInviteFlow() { } }, [activeAccount, requestChallenge, signMessageHex]); - const loadInviteProgress = useCallback(async (address?: string): Promise => { + const loadRewardsProgress = useCallback(async (address?: string): Promise => { const targetAddress = address || activeAccount; if (!targetAddress) { throw new Error('Missing wallet address'); } - return SuperheroApi.getXInviteProgress(targetAddress); + return SuperheroApi.getRewardsProgress(targetAddress); }, [activeAccount]); const bindInviteForUserB = useCallback(async (inviteCode: string, address?: string): Promise => { @@ -185,7 +185,7 @@ export function useXInviteFlow() { return { generateInviteLink, - loadInviteProgress, + loadRewardsProgress, bindInviteForUserB, }; } diff --git a/src/views/Trendminer/Invite.tsx b/src/views/Trendminer/Invite.tsx index 87d6a355f..5d688ae20 100644 --- a/src/views/Trendminer/Invite.tsx +++ b/src/views/Trendminer/Invite.tsx @@ -1,9 +1,9 @@ import { useCallback, useEffect, - useMemo, useState, } from 'react'; +import { AlertCircle, Gift } from 'lucide-react'; import { CollectRewardsCard, InvitationList, @@ -11,88 +11,86 @@ import { } from '../../features/trending/components/Invitation'; import Shell from '../../components/layout/Shell'; import { useAeSdk } from '../../hooks'; +import { CONFIG } from '../../config'; import { useXInviteFlow } from '../../hooks/useXInviteFlow'; +import { useWalletConnect } from '../../hooks/useWalletConnect'; +import { + buildXAuthorizeUrl, + generateCodeVerifier, + generateOAuthState, + getXCallbackRedirectUri, + storeXOAuthPKCE, +} from '../../utils/xOAuth'; const REFERRAL_GOAL = 10; const SHARE_GOAL = 10; +const VERIFICATION_REWARD_AE = 50; +const REFERRAL_REWARD_AE = 200; +type RewardStatus = 'not_started' | 'pending' | 'paid' | 'failed'; +const PRIZE_EXPLANATION_CLASS = 'text-lg sm:text-xl lg:text-2xl leading-[1.15] font-semibold text-white/95'; + +const isRewardAchieved = (status?: string) => status === 'pending' || status === 'paid'; const CircleMazeProgress = ({ total, done, + title, }: { total: number; done: number; + title: string; }) => { const safeDone = Math.max(0, Math.min(total, done)); const rings = Math.max(total, 1); - const size = 230; + const size = 170; const center = size / 2; - const startRadius = 28; - const radiusStep = 7.5; + const startRadius = 22; + const radiusStep = 6; return ( -
-
-
- {safeDone} - / - {total} -
-
- {Math.max(0, total - safeDone)} - {' '} - steps remaining -
-
- πŸ† - Milestone reward -
-
- -
- - {Array.from({ length: rings }).map((_, index) => { - const radius = startRadius + (rings - 1 - index) * radiusStep; - const reached = index < safeDone; - return ( - +
+ + {Array.from({ length: rings }).map((_, index) => { + const radius = startRadius + (rings - 1 - index) * radiusStep; + const reached = index < safeDone; + return ( + + + {reached && ( - {reached && ( - - )} - - ); - })} - - - - - - - - -
-
- {safeDone} - / - {total} -
-
Steps
+ )} + + ); + })} + + + + + + + + +
+
+ {safeDone} + / + {total}
+
{title}
); @@ -100,53 +98,96 @@ const CircleMazeProgress = ({ const Invite = () => { const { activeAccount } = useAeSdk(); - const { generateInviteLink, loadInviteProgress } = useXInviteFlow(); + const { reconnectWalletSession } = useWalletConnect(); + const { generateInviteLink, loadRewardsProgress } = useXInviteFlow(); const [creatingInvite, setCreatingInvite] = useState(false); const [frontendInviteLink, setFrontendInviteLink] = useState(null); - const [backendInviteLink, setBackendInviteLink] = useState(null); - const [inviteCode, setInviteCode] = useState(null); const [copied, setCopied] = useState(false); + const [connectingX, setConnectingX] = useState(false); const [error, setError] = useState(null); const [progress, setProgress] = useState<{ verified_friends_count: number; goal: number; - milestone_reward_status: 'not_started' | 'pending' | 'paid' | 'failed'; + milestone_reward_status: RewardStatus; + referral_reward_ae: number; + verification_reward_status: RewardStatus; + verification_reward_ae: number; + x_username: string | null; } | null>(null); const refreshProgress = useCallback(async () => { if (!activeAccount) return; try { - const data = await loadInviteProgress(activeAccount); + const data = await loadRewardsProgress(activeAccount); + const referralsData = data.x_invite_reward; + const verificationData = data.x_verification_reward; + setProgress({ - verified_friends_count: data.verified_friends_count || 0, - goal: data.goal || REFERRAL_GOAL, - milestone_reward_status: data.milestone_reward_status || 'not_started', + verified_friends_count: referralsData?.verified_friends_count || 0, + goal: referralsData?.goal || REFERRAL_GOAL, + milestone_reward_status: (referralsData?.milestone_reward_status || 'not_started') as RewardStatus, + referral_reward_ae: REFERRAL_REWARD_AE, + verification_reward_status: (verificationData?.status || 'not_started') as RewardStatus, + verification_reward_ae: VERIFICATION_REWARD_AE, + x_username: verificationData?.x_username || null, }); } catch (err: any) { - setError((prev) => prev || err?.message || 'Failed to load invite progress'); + setError((prev) => prev || err?.message || 'Failed to load rewards progress'); } - }, [activeAccount, loadInviteProgress]); + }, [activeAccount, loadRewardsProgress]); useEffect(() => { refreshProgress(); }, [refreshProgress]); const referralsDone = progress?.verified_friends_count || 0; - const referralsGoal = progress?.goal || REFERRAL_GOAL; - const milestoneStatus = progress?.milestone_reward_status || 'not_started'; + const referralGoal = progress?.goal || REFERRAL_GOAL; + const referralRewardAchieved = isRewardAchieved(progress?.milestone_reward_status) + || referralsDone >= referralGoal; + const referralRewardAmount = progress?.referral_reward_ae || REFERRAL_REWARD_AE; + const verificationRewardAchieved = isRewardAchieved(progress?.verification_reward_status); + const verificationRewardAmount = progress?.verification_reward_ae || VERIFICATION_REWARD_AE; + const isXConnected = Boolean(progress?.x_username) + || progress?.verification_reward_status === 'pending' + || progress?.verification_reward_status === 'paid'; + let connectXButtonLabel = 'Connect X account'; + if (connectingX) connectXButtonLabel = 'Redirecting to X...'; + if (isXConnected) { + connectXButtonLabel = progress?.x_username + ? `X connected (@${progress.x_username})` + : 'X connected'; + } - const milestoneBadgeClass = useMemo(() => { - switch (milestoneStatus) { - case 'paid': - return 'bg-emerald-500/20 border-emerald-400/40 text-emerald-200'; - case 'pending': - return 'bg-amber-500/20 border-amber-400/40 text-amber-200'; - case 'failed': - return 'bg-red-500/20 border-red-400/40 text-red-200'; - default: - return 'bg-white/10 border-white/20 text-white/80'; + const onConnectX = useCallback(async () => { + if (!activeAccount || !(CONFIG as any).X_OAUTH_CLIENT_ID) return; + setError(null); + setConnectingX(true); + try { + const ready = await reconnectWalletSession(activeAccount); + if (!ready) throw new Error('Wallet session is not ready. Please connect wallet and try again.'); + + const redirectUri = getXCallbackRedirectUri(); + const state = generateOAuthState(); + const codeVerifier = generateCodeVerifier(); + storeXOAuthPKCE({ + state, + codeVerifier, + address: activeAccount, + redirectUri, + }); + const url = await buildXAuthorizeUrl({ + clientId: (CONFIG as any).X_OAUTH_CLIENT_ID, + redirectUri, + state, + codeVerifier, + }); + window.location.href = url; + } catch (err: any) { + setError(err?.message || 'Failed to start X account connection'); + } finally { + setConnectingX(false); } - }, [milestoneStatus]); + }, [activeAccount, reconnectWalletSession]); const onGenerateInvite = useCallback(async () => { if (!activeAccount) return; @@ -154,9 +195,7 @@ const Invite = () => { setCreatingInvite(true); try { const invite = await generateInviteLink(activeAccount); - setInviteCode(invite.code); setFrontendInviteLink(invite.frontend_invite_link); - setBackendInviteLink(invite.backend_invite_link); await refreshProgress(); } catch (err: any) { setError(err?.message || 'Failed to generate invite link'); @@ -165,6 +204,13 @@ const Invite = () => { } }, [activeAccount, generateInviteLink, refreshProgress]); + const onCopyInviteLink = useCallback(async () => { + if (!frontendInviteLink) return; + await navigator.clipboard.writeText(frontendInviteLink); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }, [frontendInviteLink]); + return (
@@ -178,16 +224,44 @@ const Invite = () => {
-
+

Reward

- - 50 AE + + + + {verificationRewardAmount} + {' '} + AE +
-

+

Connect Twitter (minimum 50 followers) to claim your first mission reward.

+ {(CONFIG as any).X_OAUTH_CLIENT_ID && ( + + )}
{process.env.VITE_UNFINISHED_FEATURES === 'true' && ( @@ -198,41 +272,47 @@ const Invite = () => { 150 AE
-

+

Post 10/10 tweets mentioning the Superhero link or account. This mission is layout-only for now.

- + )} -
+

Referrals

- - 200 AE + + + + {referralRewardAmount} + {' '} + AE +
-

- Invite 10 users and have them connect Twitter. -

-
- - Progress: +
+

+ Invite {' '} - - {referralsDone} - / - {referralsGoal} - - - - {milestoneStatus} - + {referralGoal} + {' '} + users and have them connect Twitter +

+
- -
+
)}
{frontendInviteLink && ( -
-
- Invite code: - {' '} - {inviteCode} +
+
+ {frontendInviteLink} +
+ )} {error && ( -
- {error} +
+ +
{error}
)}
-
+
-

+

Affiliate

-

+

Invite 3/3 users with AE amount. When they trade, you make 0.5%.