mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-20 19:51:05 +00:00
feat(referral): Implement new user referral system
This commit is contained in:
@@ -0,0 +1,276 @@
|
|||||||
|
import type { ApiPromise } from '@polkadot/api';
|
||||||
|
import type { InjectedAccountWithMeta } from '@polkadot/extension-inject/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Referral System Integration with pallet_referral
|
||||||
|
*
|
||||||
|
* Provides functions to interact with the referral pallet on PezkuwiChain.
|
||||||
|
*
|
||||||
|
* Workflow:
|
||||||
|
* 1. User A calls initiateReferral(userB_address) -> creates pending referral
|
||||||
|
* 2. User B completes KYC and gets approved
|
||||||
|
* 3. Pallet automatically confirms referral via OnKycApproved hook
|
||||||
|
* 4. User A's referral count increases
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ReferralInfo {
|
||||||
|
referrer: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferralStats {
|
||||||
|
referralCount: number;
|
||||||
|
referralScore: number;
|
||||||
|
whoInvitedMe: string | null;
|
||||||
|
pendingReferral: string | null; // Who invited me (if pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a referral for a new user
|
||||||
|
*
|
||||||
|
* @param api Polkadot API instance
|
||||||
|
* @param signer User's Polkadot account with extension
|
||||||
|
* @param referredAddress Address of the user being referred
|
||||||
|
* @returns Transaction hash
|
||||||
|
*/
|
||||||
|
export async function initiateReferral(
|
||||||
|
api: ApiPromise,
|
||||||
|
signer: InjectedAccountWithMeta,
|
||||||
|
referredAddress: string
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
try {
|
||||||
|
const tx = api.tx.referral.initiateReferral(referredAddress);
|
||||||
|
|
||||||
|
await tx.signAndSend(
|
||||||
|
signer.address,
|
||||||
|
{ signer: signer.signer },
|
||||||
|
({ status, events, dispatchError }) => {
|
||||||
|
if (dispatchError) {
|
||||||
|
if (dispatchError.isModule) {
|
||||||
|
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||||
|
const error = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||||
|
reject(new Error(error));
|
||||||
|
} else {
|
||||||
|
reject(new Error(dispatchError.toString()));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.isInBlock || status.isFinalized) {
|
||||||
|
const hash = status.asInBlock?.toString() || status.asFinalized?.toString() || '';
|
||||||
|
resolve(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the pending referral for a user (who invited them, if they haven't completed KYC)
|
||||||
|
*
|
||||||
|
* @param api Polkadot API instance
|
||||||
|
* @param address User address
|
||||||
|
* @returns Referrer address if pending, null otherwise
|
||||||
|
*/
|
||||||
|
export async function getPendingReferral(
|
||||||
|
api: ApiPromise,
|
||||||
|
address: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await api.query.referral.pendingReferrals(address);
|
||||||
|
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching pending referral:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the number of successful referrals for a user
|
||||||
|
*
|
||||||
|
* @param api Polkadot API instance
|
||||||
|
* @param address User address
|
||||||
|
* @returns Number of confirmed referrals
|
||||||
|
*/
|
||||||
|
export async function getReferralCount(
|
||||||
|
api: ApiPromise,
|
||||||
|
address: string
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
const count = await api.query.referral.referralCount(address);
|
||||||
|
return count.toNumber();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching referral count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get referral info for a user (who referred them, when)
|
||||||
|
*
|
||||||
|
* @param api Polkadot API instance
|
||||||
|
* @param address User address who was referred
|
||||||
|
* @returns ReferralInfo if exists, null otherwise
|
||||||
|
*/
|
||||||
|
export async function getReferralInfo(
|
||||||
|
api: ApiPromise,
|
||||||
|
address: string
|
||||||
|
): Promise<ReferralInfo | null> {
|
||||||
|
try {
|
||||||
|
const result = await api.query.referral.referrals(address);
|
||||||
|
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.toJSON() as any;
|
||||||
|
return {
|
||||||
|
referrer: data.referrer,
|
||||||
|
createdAt: parseInt(data.createdAt),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching referral info:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate referral score based on referral count
|
||||||
|
*
|
||||||
|
* This mirrors the logic in pallet_referral::ReferralScoreProvider
|
||||||
|
* Score calculation:
|
||||||
|
* - 0 referrals = 0 points
|
||||||
|
* - 1-10 referrals = count * 10 points (10, 20, 30, ..., 100)
|
||||||
|
* - 11-50 referrals = 100 + (count - 10) * 5 points (105, 110, ..., 300)
|
||||||
|
* - 51-100 referrals = 300 + (count - 50) * 4 points (304, 308, ..., 500)
|
||||||
|
* - 101+ referrals = 500 points (maximum capped)
|
||||||
|
*
|
||||||
|
* @param referralCount Number of confirmed referrals
|
||||||
|
* @returns Referral score
|
||||||
|
*/
|
||||||
|
export function calculateReferralScore(referralCount: number): number {
|
||||||
|
if (referralCount === 0) return 0;
|
||||||
|
if (referralCount <= 10) return referralCount * 10;
|
||||||
|
if (referralCount <= 50) return 100 + (referralCount - 10) * 5;
|
||||||
|
if (referralCount <= 100) return 300 + (referralCount - 50) * 4;
|
||||||
|
return 500; // Max score
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comprehensive referral statistics for a user
|
||||||
|
*
|
||||||
|
* @param api Polkadot API instance
|
||||||
|
* @param address User address
|
||||||
|
* @returns Complete referral stats
|
||||||
|
*/
|
||||||
|
export async function getReferralStats(
|
||||||
|
api: ApiPromise,
|
||||||
|
address: string
|
||||||
|
): Promise<ReferralStats> {
|
||||||
|
try {
|
||||||
|
const [referralCount, referralInfo, pendingReferral] = await Promise.all([
|
||||||
|
getReferralCount(api, address),
|
||||||
|
getReferralInfo(api, address),
|
||||||
|
getPendingReferral(api, address),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const referralScore = calculateReferralScore(referralCount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
referralCount,
|
||||||
|
referralScore,
|
||||||
|
whoInvitedMe: referralInfo?.referrer || null,
|
||||||
|
pendingReferral,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching referral stats:', error);
|
||||||
|
return {
|
||||||
|
referralCount: 0,
|
||||||
|
referralScore: 0,
|
||||||
|
whoInvitedMe: null,
|
||||||
|
pendingReferral: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all users who were referred by this user
|
||||||
|
* (Note: requires iterating storage which can be expensive)
|
||||||
|
*
|
||||||
|
* @param api Polkadot API instance
|
||||||
|
* @param referrerAddress Referrer's address
|
||||||
|
* @returns Array of addresses referred by this user
|
||||||
|
*/
|
||||||
|
export async function getMyReferrals(
|
||||||
|
api: ApiPromise,
|
||||||
|
referrerAddress: string
|
||||||
|
): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const entries = await api.query.referral.referrals.entries();
|
||||||
|
|
||||||
|
const myReferrals = entries
|
||||||
|
.filter(([_key, value]) => {
|
||||||
|
if (value.isEmpty) return false;
|
||||||
|
const data = value.toJSON() as any;
|
||||||
|
return data.referrer === referrerAddress;
|
||||||
|
})
|
||||||
|
.map(([key]) => {
|
||||||
|
// Extract the referred address from the storage key
|
||||||
|
const addressHex = key.args[0].toString();
|
||||||
|
return addressHex;
|
||||||
|
});
|
||||||
|
|
||||||
|
return myReferrals;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching my referrals:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to referral events for real-time updates
|
||||||
|
*
|
||||||
|
* @param api Polkadot API instance
|
||||||
|
* @param callback Callback function for events
|
||||||
|
* @returns Unsubscribe function
|
||||||
|
*/
|
||||||
|
export async function subscribeToReferralEvents(
|
||||||
|
api: ApiPromise,
|
||||||
|
callback: (event: { type: 'initiated' | 'confirmed'; referrer: string; referred: string; count?: number }) => void
|
||||||
|
): Promise<() => void> {
|
||||||
|
const unsub = await api.query.system.events((events) => {
|
||||||
|
events.forEach((record) => {
|
||||||
|
const { event } = record;
|
||||||
|
|
||||||
|
if (event.section === 'referral') {
|
||||||
|
if (event.method === 'ReferralInitiated') {
|
||||||
|
const [referrer, referred] = event.data as any;
|
||||||
|
callback({
|
||||||
|
type: 'initiated',
|
||||||
|
referrer: referrer.toString(),
|
||||||
|
referred: referred.toString(),
|
||||||
|
});
|
||||||
|
} else if (event.method === 'ReferralConfirmed') {
|
||||||
|
const [referrer, referred, newCount] = event.data as any;
|
||||||
|
callback({
|
||||||
|
type: 'confirmed',
|
||||||
|
referrer: referrer.toString(),
|
||||||
|
referred: referred.toString(),
|
||||||
|
count: newCount.toNumber(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsub;
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||||
|
import {
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Share2,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
Twitter,
|
||||||
|
Facebook,
|
||||||
|
Linkedin
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface InviteUserModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InviteUserModal: React.FC<InviteUserModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const { api, selectedAccount } = usePolkadot();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [inviteeAddress, setInviteeAddress] = useState('');
|
||||||
|
const [initiating, setInitiating] = useState(false);
|
||||||
|
const [initiateSuccess, setInitiateSuccess] = useState(false);
|
||||||
|
const [initiateError, setInitiateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Generate referral link with user's address
|
||||||
|
const referralLink = useMemo(() => {
|
||||||
|
if (!selectedAccount?.address) return '';
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
return `${baseUrl}/be-citizen?ref=${selectedAccount.address}`;
|
||||||
|
}, [selectedAccount?.address]);
|
||||||
|
|
||||||
|
// Share text for social media
|
||||||
|
const shareText = useMemo(() => {
|
||||||
|
return `Join me on Digital Kurdistan (PezkuwiChain)! 🏛️\n\nBecome a citizen and get your Welati Tiki NFT.\n\nUse my referral link:\n${referralLink}`;
|
||||||
|
}, [referralLink]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(referralLink);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = (platform: string) => {
|
||||||
|
const encodedText = encodeURIComponent(shareText);
|
||||||
|
const encodedUrl = encodeURIComponent(referralLink);
|
||||||
|
|
||||||
|
const urls: Record<string, string> = {
|
||||||
|
whatsapp: `https://wa.me/?text=${encodedText}`,
|
||||||
|
telegram: `https://t.me/share/url?url=${encodedUrl}&text=${encodeURIComponent('Join me on Digital Kurdistan! 🏛️')}`,
|
||||||
|
twitter: `https://twitter.com/intent/tweet?text=${encodedText}`,
|
||||||
|
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||||
|
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
|
||||||
|
email: `mailto:?subject=${encodeURIComponent('Join Digital Kurdistan')}&body=${encodedText}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (urls[platform]) {
|
||||||
|
window.open(urls[platform], '_blank', 'width=600,height=400');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInitiateReferral = async () => {
|
||||||
|
if (!api || !selectedAccount || !inviteeAddress) {
|
||||||
|
setInitiateError('Please enter a valid address');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitiating(true);
|
||||||
|
setInitiateError(null);
|
||||||
|
setInitiateSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { web3FromAddress } = await import('@polkadot/extension-dapp');
|
||||||
|
const injector = await web3FromAddress(selectedAccount.address);
|
||||||
|
|
||||||
|
console.log(`Initiating referral from ${selectedAccount.address} to ${inviteeAddress}...`);
|
||||||
|
|
||||||
|
const tx = api.tx.referral.initiateReferral(inviteeAddress);
|
||||||
|
|
||||||
|
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, dispatchError }) => {
|
||||||
|
if (dispatchError) {
|
||||||
|
let errorMessage = 'Transaction failed';
|
||||||
|
if (dispatchError.isModule) {
|
||||||
|
const decoded = api.registry.findMetaError(dispatchError.asModule);
|
||||||
|
errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`;
|
||||||
|
} else {
|
||||||
|
errorMessage = dispatchError.toString();
|
||||||
|
}
|
||||||
|
console.error(errorMessage);
|
||||||
|
setInitiateError(errorMessage);
|
||||||
|
setInitiating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.isInBlock || status.isFinalized) {
|
||||||
|
console.log('Referral initiated successfully!');
|
||||||
|
setInitiateSuccess(true);
|
||||||
|
setInitiating(false);
|
||||||
|
setInviteeAddress('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to initiate referral:', err);
|
||||||
|
setInitiateError(err.message || 'Failed to initiate referral');
|
||||||
|
setInitiating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-lg bg-gray-900 border-gray-800">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white flex items-center gap-2">
|
||||||
|
<Share2 className="w-5 h-5 text-green-500" />
|
||||||
|
Invite Friends to PezkuwiChain
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-gray-400">
|
||||||
|
Share your referral link. When your friends complete KYC, you'll earn trust score points!
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 mt-4">
|
||||||
|
{/* Referral Link Display */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-gray-300">Your Referral Link</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={referralLink}
|
||||||
|
readOnly
|
||||||
|
className="bg-gray-800 border-gray-700 text-white font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleCopy}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800 shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Anyone who signs up with this link will be counted as your referral
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Referral Initiation */}
|
||||||
|
<div className="space-y-2 bg-blue-900/20 border border-blue-600/30 rounded-lg p-4">
|
||||||
|
<Label className="text-blue-300">Or Pre-Register a Friend (Advanced)</Label>
|
||||||
|
<p className="text-xs text-gray-400 mb-2">
|
||||||
|
If you know your friend's wallet address, you can pre-register them on-chain.
|
||||||
|
They must then complete KYC to finalize the referral.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={inviteeAddress}
|
||||||
|
onChange={(e) => setInviteeAddress(e.target.value)}
|
||||||
|
placeholder="Friend's wallet address"
|
||||||
|
className="bg-gray-800 border-gray-700 text-white font-mono text-sm placeholder:text-gray-500 placeholder:opacity-50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleInitiateReferral}
|
||||||
|
disabled={initiating || !inviteeAddress}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 shrink-0"
|
||||||
|
>
|
||||||
|
{initiating ? 'Initiating...' : 'Initiate'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{initiateSuccess && (
|
||||||
|
<p className="text-xs text-green-400">Referral initiated successfully!</p>
|
||||||
|
)}
|
||||||
|
{initiateError && (
|
||||||
|
<p className="text-xs text-red-400">{initiateError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share Options */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-gray-300">Share via</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* WhatsApp */}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleShare('whatsapp')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-green-400"
|
||||||
|
>
|
||||||
|
<MessageCircle className="mr-2 h-4 w-4" />
|
||||||
|
WhatsApp
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Telegram */}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleShare('telegram')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-400"
|
||||||
|
>
|
||||||
|
<MessageCircle className="mr-2 h-4 w-4" />
|
||||||
|
Telegram
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Twitter */}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleShare('twitter')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-400"
|
||||||
|
>
|
||||||
|
<Twitter className="mr-2 h-4 w-4" />
|
||||||
|
Twitter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Facebook */}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleShare('facebook')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-600"
|
||||||
|
>
|
||||||
|
<Facebook className="mr-2 h-4 w-4" />
|
||||||
|
Facebook
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* LinkedIn */}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleShare('linkedin')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
<Linkedin className="mr-2 h-4 w-4" />
|
||||||
|
LinkedIn
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<Button
|
||||||
|
onClick={() => handleShare('email')}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-300 hover:bg-gray-800 hover:text-yellow-500"
|
||||||
|
>
|
||||||
|
<Mail className="mr-2 h-4 w-4" />
|
||||||
|
Email
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rewards Info */}
|
||||||
|
<div className="bg-green-900/20 border border-green-600/30 rounded-lg p-4">
|
||||||
|
<h4 className="text-green-400 font-semibold mb-2 text-sm">Referral Rewards</h4>
|
||||||
|
<ul className="text-xs text-gray-300 space-y-1">
|
||||||
|
<li>• 1-10 referrals: 10 points each (up to 100 points)</li>
|
||||||
|
<li>• 11-50 referrals: 5 points each (up to 300 points)</li>
|
||||||
|
<li>• 51-100 referrals: 4 points each (up to 500 points)</li>
|
||||||
|
<li>• Maximum: 500 trust score points</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
className="bg-gray-800 hover:bg-gray-700 text-white"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useReferral } from '@/contexts/ReferralContext';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { InviteUserModal } from './InviteUserModal';
|
||||||
|
import { Users, UserPlus, Trophy, Award, Loader2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export const ReferralDashboard: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { stats, myReferrals, loading } = useReferral();
|
||||||
|
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-green-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-white flex items-center gap-2">
|
||||||
|
<Users className="w-6 h-6 text-green-500" />
|
||||||
|
Referral System
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mt-1">
|
||||||
|
Invite friends to PezkuwiChain and earn trust score
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowInviteModal(true)}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white"
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Invite Friend
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
{/* Referral Count */}
|
||||||
|
<Card className="bg-gray-900 border-gray-800">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-green-500" />
|
||||||
|
Total Referrals
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
Confirmed referrals (KYC completed)
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-4xl font-bold text-green-500">
|
||||||
|
{stats?.referralCount ?? 0}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Trust Score */}
|
||||||
|
<Card className="bg-gray-900 border-gray-800">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||||
|
Trust Score
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
Reputation score from referrals
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-4xl font-bold text-yellow-500">
|
||||||
|
{stats?.referralScore ?? 0}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
Max: 500 points
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Who Invited Me */}
|
||||||
|
<Card className="bg-gray-900 border-gray-800">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
|
<Award className="w-5 h-5 text-blue-500" />
|
||||||
|
Invited By
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
Your referrer
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{stats?.whoInvitedMe ? (
|
||||||
|
<div className="text-sm font-mono text-blue-400 break-all">
|
||||||
|
{stats.whoInvitedMe.slice(0, 8)}...{stats.whoInvitedMe.slice(-6)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-500 text-sm">
|
||||||
|
No referrer
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Score Breakdown */}
|
||||||
|
<Card className="bg-gray-900 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Score Calculation</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
How referrals contribute to your trust score
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||||
|
<span className="text-gray-300">1-10 referrals</span>
|
||||||
|
<span className="text-green-400 font-semibold">10 points each</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||||
|
<span className="text-gray-300">11-50 referrals</span>
|
||||||
|
<span className="text-blue-400 font-semibold">100 + 5 points each</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||||
|
<span className="text-gray-300">51-100 referrals</span>
|
||||||
|
<span className="text-yellow-400 font-semibold">300 + 4 points each</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg">
|
||||||
|
<span className="text-gray-300">101+ referrals</span>
|
||||||
|
<span className="text-red-400 font-semibold">500 points (max)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* My Referrals List */}
|
||||||
|
<Card className="bg-gray-900 border-gray-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-green-500" />
|
||||||
|
My Referrals ({myReferrals.length})
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-gray-400">
|
||||||
|
Users you have successfully referred
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{myReferrals.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Users className="w-12 h-12 text-gray-600 mx-auto mb-3" />
|
||||||
|
<p className="text-gray-500">No referrals yet</p>
|
||||||
|
<p className="text-gray-600 text-sm mt-1">
|
||||||
|
Invite friends to start building your network
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowInviteModal(true)}
|
||||||
|
className="mt-4 bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
Send First Invitation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{myReferrals.map((address, index) => (
|
||||||
|
<div
|
||||||
|
key={address}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-800/50 rounded-lg hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-green-600/20 flex items-center justify-center">
|
||||||
|
<span className="text-green-400 font-semibold text-sm">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-mono text-white">
|
||||||
|
{address.slice(0, 10)}...{address.slice(-8)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
KYC Completed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-green-400 text-sm font-semibold">
|
||||||
|
+{index < 10 ? 10 : index < 50 ? 5 : index < 100 ? 4 : 0} points
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Pending Referral Notification */}
|
||||||
|
{stats?.pendingReferral && (
|
||||||
|
<Card className="bg-blue-900/20 border-blue-600/30">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-blue-600/30 flex items-center justify-center">
|
||||||
|
<Award className="w-5 h-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-white font-semibold">Pending Invitation</div>
|
||||||
|
<div className="text-sm text-blue-300">
|
||||||
|
Complete KYC to confirm your referral from{' '}
|
||||||
|
<span className="font-mono">
|
||||||
|
{stats.pendingReferral.slice(0, 8)}...{stats.pendingReferral.slice(-6)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Invite Modal */}
|
||||||
|
<InviteUserModal
|
||||||
|
isOpen={showInviteModal}
|
||||||
|
onClose={() => setShowInviteModal(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
|
import { usePolkadot } from '@/contexts/PolkadotContext';
|
||||||
|
import { useWallet } from '@/contexts/WalletContext';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import {
|
||||||
|
getReferralStats,
|
||||||
|
getMyReferrals,
|
||||||
|
initiateReferral,
|
||||||
|
subscribeToReferralEvents,
|
||||||
|
type ReferralStats,
|
||||||
|
} from '@pezkuwi/lib/referral';
|
||||||
|
|
||||||
|
interface ReferralContextValue {
|
||||||
|
stats: ReferralStats | null;
|
||||||
|
myReferrals: string[];
|
||||||
|
loading: boolean;
|
||||||
|
inviteUser: (referredAddress: string) => Promise<boolean>;
|
||||||
|
refreshStats: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReferralContext = createContext<ReferralContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ReferralProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { api, isApiReady } = usePolkadot();
|
||||||
|
const { account } = useWallet();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const [stats, setStats] = useState<ReferralStats | null>(null);
|
||||||
|
const [myReferrals, setMyReferrals] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fetch referral statistics
|
||||||
|
const fetchStats = useCallback(async () => {
|
||||||
|
if (!api || !isApiReady || !account) {
|
||||||
|
setStats(null);
|
||||||
|
setMyReferrals([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const [fetchedStats, fetchedReferrals] = await Promise.all([
|
||||||
|
getReferralStats(api, account),
|
||||||
|
getMyReferrals(api, account),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStats(fetchedStats);
|
||||||
|
setMyReferrals(fetchedReferrals);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching referral stats:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to load referral statistics',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, isApiReady, account, toast]);
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, [fetchStats]);
|
||||||
|
|
||||||
|
// Subscribe to referral events for real-time updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (!api || !isApiReady || !account) return;
|
||||||
|
|
||||||
|
let unsub: (() => void) | undefined;
|
||||||
|
|
||||||
|
subscribeToReferralEvents(api, (event) => {
|
||||||
|
// If this user is involved in the event, refresh stats
|
||||||
|
if (event.referrer === account || event.referred === account) {
|
||||||
|
if (event.type === 'initiated') {
|
||||||
|
toast({
|
||||||
|
title: 'Referral Sent',
|
||||||
|
description: `Invitation sent to ${event.referred.slice(0, 8)}...`,
|
||||||
|
});
|
||||||
|
} else if (event.type === 'confirmed') {
|
||||||
|
toast({
|
||||||
|
title: 'Referral Confirmed!',
|
||||||
|
description: `Your referral completed KYC. Total: ${event.count}`,
|
||||||
|
variant: 'default',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fetchStats();
|
||||||
|
}
|
||||||
|
}).then((unsubFn) => {
|
||||||
|
unsub = unsubFn;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unsub) unsub();
|
||||||
|
};
|
||||||
|
}, [api, isApiReady, account, toast, fetchStats]);
|
||||||
|
|
||||||
|
// Invite a new user
|
||||||
|
const inviteUser = async (referredAddress: string): Promise<boolean> => {
|
||||||
|
if (!api || !account) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Wallet not connected',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate address format
|
||||||
|
if (!referredAddress || referredAddress.length < 47) {
|
||||||
|
toast({
|
||||||
|
title: 'Invalid Address',
|
||||||
|
description: 'Please enter a valid Polkadot address',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Sending Invitation',
|
||||||
|
description: 'Please sign the transaction...',
|
||||||
|
});
|
||||||
|
|
||||||
|
await initiateReferral(api, { address: account, meta: { source: 'polkadot-js' } } as any, referredAddress);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Success!',
|
||||||
|
description: 'Referral invitation sent successfully',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh stats after successful invitation
|
||||||
|
await fetchStats();
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error inviting user:', error);
|
||||||
|
|
||||||
|
let errorMessage = 'Failed to send referral invitation';
|
||||||
|
if (error.message) {
|
||||||
|
if (error.message.includes('SelfReferral')) {
|
||||||
|
errorMessage = 'You cannot refer yourself';
|
||||||
|
} else if (error.message.includes('AlreadyReferred')) {
|
||||||
|
errorMessage = 'This user has already been referred';
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: errorMessage,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value: ReferralContextValue = {
|
||||||
|
stats,
|
||||||
|
myReferrals,
|
||||||
|
loading,
|
||||||
|
inviteUser,
|
||||||
|
refreshStats: fetchStats,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ReferralContext.Provider value={value}>{children}</ReferralContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReferral() {
|
||||||
|
const context = useContext(ReferralContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useReferral must be used within a ReferralProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user