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..4e1125a90 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -68,6 +68,105 @@ 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; +}; + +export type RewardProgressStatus = 'not_started' | 'pending' | 'paid' | 'failed'; + +export type XVerificationRewardProgress = { + status: RewardProgressStatus; + x_username: string | null; + tx_hash: string | null; + retry_count: number; + next_retry_at: string | null; + error: string | null; +}; + +export type XPostingRewardProgress = { + status: RewardProgressStatus; + x_username: string | null; + x_user_id: string | null; + qualified_posts_count: number; + threshold: number; + remaining_to_goal: number; + tx_hash: string | null; + retry_count: number; + next_retry_at: string | null; + error: string | null; +}; + +export type XInviteRewardProgress = { + inviter_address: string; + verified_friends_count: number; + goal: number; + remaining_to_goal: number; + milestone_reward_status: RewardProgressStatus; + milestone_reward_tx_hash: string | null; +}; + +export type AffiliationProgress = { + as_inviter: { + total_invitations: number; + claimed_invitations: number; + revoked_invitations: number; + pending_invitations: number; + total_amount_ae: number; + }; + as_invitee: { + total_received_invitations: number; + claimed_received_invitations: number; + }; +}; + +export type RewardsProgressResponse = { + address: string; + x_verification_reward: XVerificationRewardProgress; + x_posting_reward: XPostingRewardProgress; + x_invite_reward: XInviteRewardProgress; + affiliation: AffiliationProgress; +}; + // Superhero API client export const SuperheroApi = { async fetchJson(path: string, init?: RequestInit) { @@ -396,6 +495,39 @@ 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; + }, + getRewardsProgress(address: string) { + return this.fetchJson(`/api/profile/${encodeURIComponent(address)}/rewards-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..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, @@ -18,6 +19,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'; @@ -242,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); @@ -251,6 +254,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 +543,18 @@ 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. +
+ )}

{t('messages.connectXHint')}

@@ -553,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/features/trending/components/Invitation/CollectRewardsCard.tsx b/src/features/trending/components/Invitation/CollectRewardsCard.tsx index abb1c5de0..3e204a668 100644 --- a/src/features/trending/components/Invitation/CollectRewardsCard.tsx +++ b/src/features/trending/components/Invitation/CollectRewardsCard.tsx @@ -259,27 +259,28 @@ 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 ( -
+
{/* Header */} -
-
+
+
💰
-

- Collect your rewards +

+ Collect Affiliate Rewards

{/* Content */} -
+
{/* 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} @@ -290,12 +291,13 @@ const CollectRewardsCard = () => { have each spent at least + {' '} {MIN_SPENT_AE} {' '} AE {' '} - (cumulative). + (cumulative). Affiliate payout rate is 0.5%.

Note: eligibility and rewards depend on on-chain activity and are not guaranteed. @@ -396,7 +398,7 @@ const CollectRewardsCard = () => { type="button" onClick={onCollectReward} disabled={collectingReward || !isEligibleForRewards} - className={`w-full p-4 md:p-5 lg:p-6 text-sm md:text-base font-bold uppercase tracking-wider break-words whitespace-normal min-h-12 rounded-xl transition-all duration-300 ${isEligibleForRewards + className={`w-full p-3 md:p-3.5 text-xs md:text-sm font-semibold uppercase tracking-wide break-words whitespace-normal min-h-10 rounded-xl transition-all duration-300 ${isEligibleForRewards ? 'bg-gradient-to-r from-pink-500 to-purple-500 text-white shadow-lg shadow-pink-500/30 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-pink-500/40' : 'opacity-50 cursor-not-allowed bg-gray-600 transform-none' }`} 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..003a5a964 100644 --- a/src/features/trending/components/Invitation/InviteAndEarnCard.tsx +++ b/src/features/trending/components/Invitation/InviteAndEarnCard.tsx @@ -125,27 +125,27 @@ const InviteAndEarnCard = ({ }, [linkHasBeenCopied, pulseCloseBlocked]); return ( -
-
-
+
+
+
🎯
-

- 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 @@ -155,7 +155,7 @@ const InviteAndEarnCard = ({ {/* Form - Right Side */}

-
+
{/* Amount Input */}
@@ -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) ) : ( - + )}
diff --git a/src/features/trending/hooks/useInvitations.ts b/src/features/trending/hooks/useInvitations.ts index 2a961ea0c..46ee2a010 100644 --- a/src/features/trending/hooks/useInvitations.ts +++ b/src/features/trending/hooks/useInvitations.ts @@ -16,6 +16,7 @@ import { fetchJson } from '../../../utils/common'; import { invitationListAtom, invitationCodeAtom, + xInviteCodeAtom, claimedInvitationsAtom, recentlyRevokedInvitationsAtom, invitationLoadingAtom, @@ -25,6 +26,7 @@ import { type InvitationStatus, type ClaimedInfo, } from '../../../atoms/invitationAtoms'; +import { parseXInviteCodeFromUrl, storeXInviteCode } from '../../../utils/xInvite'; import { TX_FUNCTIONS, DATE_LONG, @@ -43,6 +45,7 @@ export function useInvitations() { // Atoms const [invitationList, setInvitationList] = useAtom(invitationListAtom); const [invitationCode, setInvitationCode] = useAtom(invitationCodeAtom); + const [, setXInviteCode] = useAtom(xInviteCodeAtom); const [claimedInvitations, setClaimedInvitations] = useAtom(claimedInvitationsAtom); const [ recentlyRevokedInvitations, @@ -287,6 +290,12 @@ export function useInvitations() { // Handle URL hash changes for invitation codes useEffect(() => { + 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__/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 new file mode 100644 index 000000000..2e0673238 --- /dev/null +++ b/src/hooks/__tests__/useXInviteFlow.test.ts @@ -0,0 +1,142 @@ +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 mockGetRewardsProgress = vi.fn(); +const mockVerifyMessage = vi.fn(() => true); +const mockSignMessageWithFallback = vi.fn(); + +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), + getRewardsProgress: (...args: any[]) => mockGetRewardsProgress(...args), + }, +})); + +vi.mock('@/hooks/useAeSdk', () => ({ + useAeSdk: () => ({ + activeAccount: 'ak_2aM8y71tVfYhMFnN2tFxzpcCGx8Y48Yxj6P8d7Vn2MUP6oQm1g', + }), +})); + +vi.mock('@/hooks/useWalletOperations', () => ({ + useWalletOperations: () => ({ + signMessageWithFallback: (...args: any[]) => mockSignMessageWithFallback(...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); + 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', + method: 'sdk', + }); + }); + + 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(mockSignMessageWithFallback).toHaveBeenCalledWith('please sign this message', '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/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 new file mode 100644 index 000000000..0c5bf7710 --- /dev/null +++ b/src/hooks/useXInviteFlow.ts @@ -0,0 +1,192 @@ +import { useCallback } from 'react'; +import { verifyMessage } from '@aeternity/aepp-sdk'; +import { + SuperheroApi, + type XInviteChallengePurpose, + type RewardsProgressResponse, +} from '@/api/backend'; +import { useAeSdk } from '@/hooks/useAeSdk'; +import { useWalletOperations } from '@/hooks/useWalletOperations'; +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); +} + +type SignOutcome = { + signatureHex: string; + method: 'sdk' | 'reconnect' | 'deeplink'; +}; + +export function useXInviteFlow() { + const { activeAccount } = useAeSdk(); + const { signMessageWithFallback } = useWalletOperations(); + + 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 (targetAddress) { + const isValid = verifyMessage(message, hexToBytes(signatureHex), targetAddress); + if (!isValid) { + throw new Error('Signed message does not match inviter wallet address'); + } + } + return { + signatureHex, + method: result.method, + }; + }, [activeAccount, signMessageWithFallback]); + + 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 signOutcome = await signMessageHex(challenge.message, signerAddress); + const { signatureHex } = signOutcome; + 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), + sign_method: signOutcome.method, + error_message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, [activeAccount, requestChallenge, signMessageHex]); + + const loadRewardsProgress = useCallback(async (address?: string): Promise => { + const targetAddress = address || activeAccount; + if (!targetAddress) { + throw new Error('Missing wallet address'); + } + return SuperheroApi.getRewardsProgress(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 signOutcome = await signMessageHex(challenge.message, inviteeAddress); + const { signatureHex } = signOutcome; + 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), + sign_method: signOutcome.method, + error_message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, [activeAccount, requestChallenge, signMessageHex]); + + return { + generateInviteLink, + loadRewardsProgress, + bindInviteForUserB, + }; +} + 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/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..eff522dc9 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,42 @@ 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'); + 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')); + } + } + } + const att = await SuperheroApi.createXAttestationFromCode( stored.address, code, @@ -112,10 +156,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..5d688ae20 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, + useState, +} from 'react'; +import { AlertCircle, Gift } from 'lucide-react'; import { CollectRewardsCard, InvitationList, @@ -11,111 +11,369 @@ 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 = 170; + const center = size / 2; + const startRadius = 22; + const radiusStep = 6; + + return ( +
+ + {Array.from({ length: rings }).map((_, index) => { + const radius = startRadius + (rings - 1 - index) * radiusStep; + const reached = index < safeDone; + return ( + + + {reached && ( + + )} + + ); + })} + + + + + + + + +
+
+ {safeDone} + / + {total} +
+
{title}
+
+
+ ); +}; -export default function Invite() { +const Invite = () => { const { activeAccount } = useAeSdk(); - const [showInfo, setShowInfo] = useState(() => { + const { reconnectWalletSession } = useWalletConnect(); + const { generateInviteLink, loadRewardsProgress } = useXInviteFlow(); + const [creatingInvite, setCreatingInvite] = useState(false); + const [frontendInviteLink, setFrontendInviteLink] = 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: 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 loadRewardsProgress(activeAccount); + const referralsData = data.x_invite_reward; + const verificationData = data.x_verification_reward; + + setProgress({ + 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 rewards progress'); + } + }, [activeAccount, loadRewardsProgress]); + + useEffect(() => { + refreshProgress(); + }, [refreshProgress]); + + const referralsDone = progress?.verified_friends_count || 0; + 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 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); + } + }, [activeAccount, reconnectWalletSession]); + + 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); + setFrontendInviteLink(invite.frontend_invite_link); + await refreshProgress(); + } catch (err: any) { + setError(err?.message || 'Failed to generate invite link'); + } finally { + setCreatingInvite(false); } - }); + }, [activeAccount, generateInviteLink, refreshProgress]); + + const onCopyInviteLink = useCallback(async () => { + if (!frontendInviteLink) return; + await navigator.clipboard.writeText(frontendInviteLink); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + }, [frontendInviteLink]); 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 */} - - -
-
- 💡 + +
+
+
+

Reward

+ + + + {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' && ( +
+
+

Share

+ + 150 AE +
-
-

- 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 -
-
+

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

+ +
+ )} + +
+
+

Referrals

+ + + + {referralRewardAmount} + {' '} + AE + + +
+
+

+ Invite + {' '} + {referralGoal} + {' '} + users and have them connect Twitter +

+ +
+ +
+ + {frontendInviteLink && ( + + )} +
+ {frontendInviteLink && ( + + )} + {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/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 && (