Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -84,6 +85,13 @@ const App = () => {
};
}, [activeAccount]);

useEffect(() => {
const parsedCode = parseXInviteCodeFromWindow();
if (parsedCode) {
storeXInviteCode(parsedCode);
}
}, []);

return (
<div className="app-container">

Expand Down
132 changes: 132 additions & 0 deletions src/api/backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -396,6 +495,39 @@ export const SuperheroApi = {
}),
}) as Promise<XAttestationResponse>;
},
createXInviteChallenge(payload: XInviteChallengeRequest) {
return this.fetchJson('/api/profile/x-invites/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}) as Promise<XInviteChallengeResponse>;
},
createXInvite(payload: XInviteCreateRequest) {
return this.fetchJson('/api/profile/x-invites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}) as Promise<XInviteCreateResponse>;
},
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<XInviteProgressResponse>;
},
getRewardsProgress(address: string) {
return this.fetchJson(`/api/profile/${encodeURIComponent(address)}/rewards-progress`) as Promise<RewardsProgressResponse>;
},
/** @deprecated Legacy profile update flow; use on-chain writes instead. */
issueProfileChallenge(address: string, payload: ProfileEditablePayload) {
return this.fetchJson(`/api/profile/${encodeURIComponent(address)}/challenge`, {
Expand Down
1 change: 1 addition & 0 deletions src/atoms/invitationAtoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const invitationListAtom = atomWithStorage<InvitationInfo[]>('invite_list

// Current invitation code from URL
export const invitationCodeAtom = atomWithStorage<string | undefined>('invite_code', undefined);
export const xInviteCodeAtom = atomWithStorage<string | undefined>('x_invite_code', undefined);

// Claimed invitations cache - stores claimer info
export interface ClaimedInfo {
Expand Down
20 changes: 20 additions & 0 deletions src/components/modals/ProfileEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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<ProfileFormState>(EMPTY_FORM);
const [initialForm, setInitialForm] = useState<ProfileFormState>(EMPTY_FORM);
Expand All @@ -251,6 +254,7 @@ const ProfileEditModal = ({
const [xUsername, setXUsername] = useState<string | null>(null);
const [xSectionReady, setXSectionReady] = useState(false);
const [availableChainNames, setAvailableChainNames] = useState<OwnedChainNameOption[]>([]);
const xInviteCode = getStoredXInviteCode();
const xSectionRef = useRef<HTMLDivElement | null>(null);
const connectXButtonRef = useRef<HTMLButtonElement | null>(null);

Expand Down Expand Up @@ -539,6 +543,18 @@ const ProfileEditModal = ({
)}
{xSectionReady && !hasXVerified && (
<>
{!walletConnected && (
<div className="mb-2 rounded-xl border border-amber-400/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Wallet connection is required for invite bind.
{' '}
We will try to reconnect before opening X.
</div>
)}
{xInviteCode && (
<div className="mb-2 rounded-xl border border-blue-400/30 bg-blue-500/10 px-3 py-2 text-xs text-blue-100">
You were invited by a friend. Connect X to complete the invite mission.
</div>
)}
<p className="text-xs text-white/60 mt-0.5 mb-2">
{t('messages.connectXHint')}
</p>
Expand All @@ -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();
Expand Down
22 changes: 12 additions & 10 deletions src/features/trending/components/Invitation/CollectRewardsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="bg-black/20 backdrop-blur-lg border border-white/10 rounded-2xl p-6 md:p-8 lg:p-10 relative overflow-hidden min-h-0 before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-gradient-to-r before:from-pink-400 before:via-purple-400 before:to-blue-400 before:opacity-0 before:transition-opacity before:duration-300 hover:before:opacity-100">
<div className="bg-black/20 backdrop-blur-lg border border-white/10 rounded-2xl p-4 md:p-5 relative overflow-hidden min-h-0 before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-gradient-to-r before:from-pink-400 before:via-purple-400 before:to-blue-400 before:opacity-0 before:transition-opacity before:duration-300 hover:before:opacity-100">
{/* Header */}
<div className="flex items-center gap-4 mb-6 flex-wrap">
<div className="text-3xl md:text-4xl lg:text-5xl drop-shadow-[0_0_8px_rgba(255,255,255,0.2)] flex-shrink-0">
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div className="text-2xl md:text-3xl drop-shadow-[0_0_8px_rgba(255,255,255,0.2)] flex-shrink-0">
💰
</div>
<h3 className="m-0 text-2xl md:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-pink-400 via-purple-400 to-blue-400 bg-clip-text text-transparent break-words">
Collect your rewards
<h3 className="m-0 text-xl md:text-2xl font-bold bg-gradient-to-r from-pink-400 via-purple-400 to-blue-400 bg-clip-text text-transparent break-words">
Collect Affiliate Rewards
</h3>
</div>

{/* Content */}
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12">
<div className="flex flex-col lg:flex-row gap-5 lg:gap-8">
{/* Description - Left Side */}
<div className="flex-1 space-y-4 text-sm text-muted-foreground">
<p>
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
{' '}
<span className="font-semibold text-white/80">
{MIN_INVITEES}
Expand All @@ -290,12 +291,13 @@ const CollectRewardsCard = () => {
have each
spent at least
<span className="font-semibold text-white/80">
{' '}
{MIN_SPENT_AE}
{' '}
AE
</span>
{' '}
(cumulative).
(cumulative). Affiliate payout rate is 0.5%.
</p>
<p className="text-xs opacity-60">
Note: eligibility and rewards depend on on-chain activity and are not guaranteed.
Expand Down Expand Up @@ -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'
}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ const InvitationList = () => {
return (
<div className="border border-white/10 rounded-lg bg-white/[0.02] backdrop-blur-sm">
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h3 className="text-lg font-semibold text-white">{t('invitations.yourInvitations')}</h3>
<h3 className="text-lg font-semibold text-white">Your Affiliate Invitations</h3>
</div>

{loading && (
Expand Down
24 changes: 12 additions & 12 deletions src/features/trending/components/Invitation/InviteAndEarnCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,27 +125,27 @@ const InviteAndEarnCard = ({
}, [linkHasBeenCopied, pulseCloseBlocked]);

return (
<div className={`bg-black/20 backdrop-blur-lg border border-white/10 rounded-2xl p-6 md:p-8 lg:p-10 relative overflow-hidden min-h-0 before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-gradient-to-r before:from-pink-400 before:via-purple-400 before:to-blue-400 before:opacity-0 before:transition-opacity before:duration-300 hover:before:opacity-100 ${className || ''}`}>
<div className="flex items-center gap-4 mb-6">
<div className="text-3xl md:text-4xl lg:text-5xl drop-shadow-[0_0_8px_rgba(255,255,255,0.2)] flex-shrink-0">
<div className={`bg-black/20 backdrop-blur-lg border border-white/10 rounded-2xl p-4 md:p-5 relative overflow-hidden min-h-0 before:content-[''] before:absolute before:top-0 before:left-0 before:right-0 before:h-px before:bg-gradient-to-r before:from-pink-400 before:via-purple-400 before:to-blue-400 before:opacity-0 before:transition-opacity before:duration-300 hover:before:opacity-100 ${className || ''}`}>
<div className="flex items-center gap-3 mb-4">
<div className="text-2xl md:text-3xl drop-shadow-[0_0_8px_rgba(255,255,255,0.2)] flex-shrink-0">
🎯
</div>
<h3 className="m-0 text-2xl md:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-pink-400 via-purple-400 to-blue-400 bg-clip-text text-transparent break-words">
Generate Invites
<h3 className="m-0 text-xl md:text-2xl font-bold bg-gradient-to-r from-pink-400 via-purple-400 to-blue-400 bg-clip-text text-transparent break-words">
Generate Affiliate Links
</h3>
</div>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12">
<div className="flex flex-col lg:flex-row gap-5 lg:gap-8">
{/* Description - Left Side */}
<div className="flex-1 space-y-4 text-sm text-muted-foreground">
<p>
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.
</p>
<p>
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.
</p>
<p className="text-xs opacity-60">
Important: save your links before closing the popup. The secret code
Expand All @@ -155,15 +155,15 @@ const InviteAndEarnCard = ({

{/* Form - Right Side */}
<div className="flex-1">
<form onSubmit={generateInviteLink} className="space-y-6">
<form onSubmit={generateInviteLink} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Amount Input */}
<div className="space-y-2">
<Label
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)
</Label>
<Input
id="amount"
Expand Down Expand Up @@ -226,7 +226,7 @@ const InviteAndEarnCard = ({
<button
type="submit"
disabled={generatingInviteLink || !activeAccount}
className={`w-full p-4 md:p-5 lg:p-6 text-sm md:text-base font-bold flex items-center justify-center gap-3 uppercase tracking-wider relative overflow-hidden break-words whitespace-normal min-h-12 rounded-xl transition-all duration-300 ${
className={`w-full p-3 md:p-3.5 text-xs md:text-sm font-semibold flex items-center justify-center gap-2.5 uppercase tracking-wide relative overflow-hidden break-words whitespace-normal min-h-10 rounded-xl transition-all duration-300 ${
!activeAccount
? 'opacity-50 cursor-not-allowed bg-gray-600 transform-none'
: "bg-gradient-to-r from-[var(--neon-teal)] to-blue-500 text-white shadow-lg hover:-translate-y-0.5 hover:shadow-xl before:content-[''] before:absolute before:top-0 before:-left-full before:w-full before:h-full before:bg-gradient-to-r before:from-transparent before:via-white/20 before:to-transparent before:transition-all before:duration-500 hover:before:left-full"
Expand All @@ -242,7 +242,7 @@ const InviteAndEarnCard = ({
)}
</button>
) : (
<WalletConnectBtn label={t('buttons.connectWalletToGenerate', { ns: 'common' })} className="text-sm" />
<WalletConnectBtn label={t('buttons.connectWalletToGenerate', { ns: 'common' })} className="text-xs md:text-sm py-2.5" />
)}
</form>
</div>
Expand Down
Loading